Compare commits

..

229 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
635 changed files with 36635 additions and 17190 deletions

View File

@@ -2,16 +2,6 @@
Docs: https://docs.openclaw.ai
## 2026.6.20-alpha.1
### Changes
- Alpha nightly from current `main`, including unreleased agent, release, plugin, UI, mobile, and channel changes since 2026.6.9.
### Fixes
- Release validation includes refreshed config documentation and plugin SDK metadata for the alpha package, and alpha packaging includes refreshed plugin version and shrinkwrap evidence for 2026.6.20-alpha.1.
## 2026.6.9
### Highlights

View File

@@ -1,4 +1,4 @@
37742164ebf1765a735c4d56000a5ba18e817b6ac71782371c863a564cf6e7c5 config-baseline.json
2923c1120c0369aeca6646cd67f7264590c6a1f4e5bc3157a04d7661324c6868 config-baseline.core.json
ac06b6c20a93a8543ec1bd3748ef4f7bdae5006839dd93b3fff874d0da4244aa config-baseline.json
e7965566fdaedef445bcd562141f4f3ea1a499cf8ea5956418af7c98049bf242 config-baseline.core.json
2d735389858305509528e74329b6f8c65d311e1471c3b4e91dc17aaab8e63a80 config-baseline.channel.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

@@ -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

@@ -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

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/anthropic-provider",
"version": "2026.6.20-alpha.1",
"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.20-alpha.1",
"version": "2026.6.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/arcee-provider",
"version": "2026.6.20-alpha.1"
"version": "2026.6.8"
}
}
}

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/azure-speech",
"version": "2026.6.20-alpha.1",
"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.20-alpha.1",
"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.20-alpha.1",
"version": "2026.6.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/brave-plugin",
"version": "2026.6.20-alpha.1"
"version": "2026.6.8"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/brave-plugin",
"version": "2026.6.20-alpha.1",
"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.20-alpha.1"
"pluginApi": ">=2026.6.8"
},
"build": {
"openclawVersion": "2026.6.20-alpha.1"
"openclawVersion": "2026.6.8"
},
"release": {
"publishToClawHub": true,

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/browser-plugin",
"version": "2026.6.20-alpha.1",
"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.20-alpha.1",
"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.20-alpha.1",
"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.20-alpha.1",
"version": "2026.6.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/cerebras-provider",
"version": "2026.6.20-alpha.1"
"version": "2026.6.8"
}
}
}

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/chutes-provider",
"version": "2026.6.20-alpha.1",
"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.20-alpha.1"
"pluginApi": ">=2026.6.8"
},
"build": {
"openclawVersion": "2026.6.20-alpha.1",
"openclawVersion": "2026.6.8",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/clickclack",
"version": "2026.6.20-alpha.1",
"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.20-alpha.1"
"openclaw": ">=2026.6.8"
},
"peerDependenciesMeta": {
"openclaw": {

View File

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

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/cloudflare-ai-gateway-provider",
"version": "2026.6.20-alpha.1",
"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.20-alpha.1"
"pluginApi": ">=2026.6.8"
},
"build": {
"openclawVersion": "2026.6.20-alpha.1",
"openclawVersion": "2026.6.8",
"bundledDist": false
},
"release": {

View File

@@ -1,6 +1,6 @@
{
"name": "@openclaw/codex-supervisor",
"version": "2026.6.20-alpha.1",
"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.20-alpha.1",
"version": "2026.6.8",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/codex",
"version": "2026.6.20-alpha.1",
"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.20-alpha.1",
"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.20-alpha.1"
"pluginApi": ">=2026.6.8"
},
"build": {
"openclawVersion": "2026.6.20-alpha.1"
"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

@@ -8,40 +8,56 @@ import {
} 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,
@@ -148,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);
@@ -164,13 +181,22 @@ 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,
@@ -196,6 +222,7 @@ 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-"));
});
@@ -231,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,
@@ -238,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(
@@ -256,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);

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);

View File

@@ -12,6 +12,7 @@ import {
type CodexInitializeParams,
type CodexInitializeResponse,
isRpcResponse,
readCodexThreadCreationResponseId,
type CodexServerNotification,
type JsonValue,
type RpcMessage,
@@ -34,6 +35,8 @@ const CODEX_APP_SERVER_PARSE_BUFFER_MAX = 1_000_000;
const CODEX_APP_SERVER_PARSE_BUFFER_MAX_LINES = 1_000;
const CODEX_DYNAMIC_TOOL_SERVER_REQUEST_TIMEOUT_MS = 600_000;
const CODEX_APP_SERVER_STDERR_TAIL_MAX = 2_000;
const CODEX_APP_SERVER_ABANDONED_THREAD_CREATION_MAX = 128;
const CODEX_APP_SERVER_LATE_THREAD_CLEANUP_TIMEOUT_MS = 5_000;
const UNPAIRED_SURROGATE_RE =
/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g;
@@ -120,7 +123,10 @@ export class CodexAppServerClient {
private readonly requestHandlers = new Set<CodexServerRequestHandler>();
private readonly notificationHandlers = new Set<CodexServerNotificationHandler>();
private readonly closeHandlers = new Set<(client: CodexAppServerClient) => void>();
private activeSharedLeaseCountProvider: (() => number | undefined) | undefined;
private readonly threadSubscriptionOwners = new Map<string, number>();
// Codex may finish a locally abandoned create request. Remember its RPC id
// until response/close so the unknown thread subscription can be released.
private readonly abandonedThreadCreationRequestIds = new Set<number | string>();
private nextId = 1;
private initialized = false;
private closed = false;
@@ -241,11 +247,27 @@ export class CodexAppServerClient {
if (options.signal?.aborted) {
return Promise.reject(new Error(`${method} aborted`));
}
const requestedThreadId = readRequestThreadId(params);
if (
method === "thread/unsubscribe" &&
requestedThreadId &&
this.releaseThreadSubscriptionOwner(requestedThreadId)
) {
// Codex subscriptions are connection-wide sets. A logical owner can
// release without silencing another turn on the same physical client.
return Promise.resolve({ status: "unsubscribed" } as unknown as T);
}
if (method === "thread/resume" && requestedThreadId) {
// Every resume attempt owns one release, even if the response times out
// or aborts: Codex may have subscribed before OpenClaw saw the outcome.
this.retainThreadSubscriptionOwner(requestedThreadId);
}
const id = this.nextId++;
const message: RpcRequest = { id, method, params: params as JsonValue | undefined };
return new Promise<T>((resolve, reject) => {
let timeout: ReturnType<typeof setTimeout> | undefined;
let cleanupAbort: (() => void) | undefined;
let requestWritten = false;
const cleanup = () => {
if (timeout) {
clearTimeout(timeout);
@@ -254,23 +276,37 @@ export class CodexAppServerClient {
cleanupAbort?.();
cleanupAbort = undefined;
};
const rejectPending = (error: Error) => {
const rejectPending = (error: Error, rememberLateThreadCreation = false) => {
if (!this.pending.has(id)) {
return;
}
this.pending.delete(id);
if (rememberLateThreadCreation && isThreadCreationRequest(method)) {
if (
this.abandonedThreadCreationRequestIds.size >=
CODEX_APP_SERVER_ABANDONED_THREAD_CREATION_MAX
) {
// Lost create responses can hide server subscriptions. Once the
// bounded cleanup ledger fills, closing is the only safe release.
this.closeWithError(
new Error("codex app-server abandoned thread creation limit exceeded"),
);
} else {
this.abandonedThreadCreationRequestIds.add(id);
}
}
cleanup();
reject(error);
};
if (options.timeoutMs && Number.isFinite(options.timeoutMs) && options.timeoutMs > 0) {
timeout = setTimeout(
() => rejectPending(new Error(`${method} timed out`)),
() => rejectPending(new Error(`${method} timed out`), true),
Math.max(100, options.timeoutMs),
);
timeout.unref?.();
}
if (options.signal) {
const abortListener = () => rejectPending(new Error(`${method} aborted`));
const abortListener = () => rejectPending(new Error(`${method} aborted`), requestWritten);
options.signal.addEventListener("abort", abortListener, { once: true });
cleanupAbort = () => options.signal?.removeEventListener("abort", abortListener);
}
@@ -278,6 +314,12 @@ export class CodexAppServerClient {
method,
resolve: (value) => {
cleanup();
if (method === "thread/start" || method === "thread/fork") {
const threadId = readCodexThreadCreationResponseId(value);
if (threadId) {
this.retainThreadSubscriptionOwner(threadId);
}
}
resolve(value as T);
},
reject: (error) => {
@@ -291,6 +333,7 @@ export class CodexAppServerClient {
return;
}
try {
requestWritten = true;
this.writeMessage(message, (error) => rejectPending(error));
} catch (error) {
rejectPending(error instanceof Error ? error : new Error(String(error)));
@@ -315,18 +358,6 @@ export class CodexAppServerClient {
return () => this.notificationHandlers.delete(handler);
}
/** Installs a lease-count provider used to route unscoped notifications. */
setActiveSharedLeaseCountProviderForUnscopedNotifications(
provider: (() => number | undefined) | undefined,
): void {
this.activeSharedLeaseCountProvider = provider;
}
/** Reads the active shared-client lease count when available. */
getActiveSharedLeaseCountForUnscopedNotifications(): number | undefined {
return this.activeSharedLeaseCountProvider?.();
}
/** Registers a close handler and returns its disposer. */
addCloseHandler(handler: (client: CodexAppServerClient) => void): () => void {
this.closeHandlers.add(handler);
@@ -445,6 +476,15 @@ export class CodexAppServerClient {
}
private handleResponse(response: RpcResponse): void {
if (this.abandonedThreadCreationRequestIds.delete(response.id)) {
if (!response.error) {
const threadId = readCodexThreadCreationResponseId(response.result);
if (threadId) {
this.unsubscribeLateThreadCreation(threadId);
}
}
return;
}
const pending = this.pending.get(response.id);
if (!pending) {
return;
@@ -522,7 +562,14 @@ export class CodexAppServerClient {
private handleNotification(notification: CodexServerNotification): void {
for (const handler of this.notificationHandlers) {
Promise.resolve(handler(notification)).catch((error: unknown) => {
let result: Promise<void> | void;
try {
result = handler(notification);
} catch (error) {
embeddedAgentLog.warn("codex app-server notification handler failed", { error });
continue;
}
Promise.resolve(result).catch((error: unknown) => {
embeddedAgentLog.warn("codex app-server notification handler failed", { error });
});
}
@@ -540,11 +587,54 @@ export class CodexAppServerClient {
}
this.closed = true;
this.closeError = error;
this.threadSubscriptionOwners.clear();
this.abandonedThreadCreationRequestIds.clear();
this.lines.close();
this.rejectPendingRequests(error);
return true;
}
private unsubscribeLateThreadCreation(threadId: string): void {
// This late response never registered a local owner. Track the wire
// release anyway; an unconfirmed cleanup makes this client unsafe to pool.
void this.request(
"thread/unsubscribe",
{ threadId },
{ timeoutMs: CODEX_APP_SERVER_LATE_THREAD_CLEANUP_TIMEOUT_MS },
).catch((error: unknown) => {
embeddedAgentLog.debug("codex app-server late thread unsubscribe failed", {
threadId,
error,
});
this.closeWithError(
new Error(`Codex late thread subscription could not be released: ${threadId}`, {
cause: error,
}),
);
});
}
private retainThreadSubscriptionOwner(threadId: string): void {
this.threadSubscriptionOwners.set(
threadId,
(this.threadSubscriptionOwners.get(threadId) ?? 0) + 1,
);
}
/** Returns true when another local owner still needs the wire subscription. */
private releaseThreadSubscriptionOwner(threadId: string): boolean {
const owners = this.threadSubscriptionOwners.get(threadId);
if (owners === undefined) {
return false;
}
if (owners > 1) {
this.threadSubscriptionOwners.set(threadId, owners - 1);
return true;
}
this.threadSubscriptionOwners.delete(threadId);
return false;
}
private rejectPendingRequests(error: Error): void {
for (const pending of this.pending.values()) {
pending.cleanup();
@@ -557,6 +647,17 @@ export class CodexAppServerClient {
}
}
function readRequestThreadId(value: unknown): string | undefined {
if (!isJsonObject(value) || typeof value.threadId !== "string") {
return undefined;
}
return value.threadId.trim() || undefined;
}
function isThreadCreationRequest(method: string): boolean {
return method === "thread/start" || method === "thread/fork";
}
function defaultServerRequestResponse(
request: Required<Pick<RpcRequest, "id" | "method">> & { params?: JsonValue },
): JsonValue {
@@ -571,6 +672,9 @@ function defaultServerRequestResponse(
success: false,
};
}
if (request.method === "execCommandApproval" || request.method === "applyPatchApproval") {
return { decision: "denied" };
}
if (
request.method === "item/commandExecution/requestApproval" ||
request.method === "item/fileChange/requestApproval"
@@ -586,6 +690,12 @@ function defaultServerRequestResponse(
reason: "OpenClaw codex app-server bridge does not grant native approvals yet.",
};
}
if (request.method.includes("requestApproval")) {
return {
decision: "decline",
reason: "OpenClaw codex app-server bridge does not grant unknown native approvals.",
};
}
if (request.method === "item/tool/requestUserInput") {
return {
answers: {},

File diff suppressed because it is too large Load Diff

View File

@@ -7,145 +7,396 @@ import {
type EmbeddedAgentCompactResult,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import {
defaultLeasedCodexAppServerClientFactory,
type CodexAppServerClientFactory,
} from "./client-factory.js";
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
isCodexAppServerUnsafeSubscriptionError,
settleCodexAppServerClientLease,
} from "./attempt-client-cleanup.js";
import { readCodexNotificationItem } from "./attempt-notifications.js";
import { resolveCodexTurnTerminalIdleTimeoutMs } from "./attempt-timeouts.js";
import { CodexAppServerRpcError } from "./client.js";
import { resolveCodexAppServerRuntimeOptions } from "./config.js";
import type { JsonObject } from "./protocol.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
import { resolveCodexNativeExecutionBlock } from "./sandbox-guard.js";
import {
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
readCodexAppServerBinding,
withCodexAppServerBindingLock,
writeCodexAppServerBinding,
sessionBindingIdentity,
type CodexAppServerBindingIdentity,
type CodexAppServerBindingStore,
type CodexAppServerThreadBinding,
} from "./session-binding.js";
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
import {
leaseSharedCodexAppServerClient,
type CodexAppServerClientLease,
type CodexAppServerClientLeaseFactory,
type CodexAppServerClientOptions,
} from "./shared-client.js";
import { resumeCodexAppServerThread } from "./thread-resume.js";
import { withTimeout } from "./timeout.js";
import {
getCodexAppServerTurnRouter,
isCodexTerminalTurnNotification,
type CodexNativeTurnCompletionWatch,
type CodexThreadRouteReservation,
} from "./turn-router.js";
const warnedIgnoredCompactionOverrides = new Set<string>();
type CodexAppServerCompactOptions = {
bindingStore: CodexAppServerBindingStore;
pluginConfig?: unknown;
clientFactory?: CodexAppServerClientFactory;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
allowNonManualNativeRequest?: boolean;
};
class CodexNativeTurnBindingChangedError extends Error {}
type CodexNativeTurnRequest = {
bindingStore: CodexAppServerBindingStore;
bindingIdentity: CodexAppServerBindingIdentity;
expectedBinding: CodexAppServerThreadBinding;
pluginConfig?: unknown;
authProfileId?: string;
agentDir?: string;
config?: CodexAppServerClientOptions["config"];
abortSignal?: AbortSignal;
clientLeaseFactory?: CodexAppServerClientLeaseFactory;
};
export type CodexNativeTurnKind = "compact" | "review";
/** Starts one native Codex turn and retains its app-server owner through completion. */
export async function requestCodexNativeTurnForBinding(
params: CodexNativeTurnRequest,
kind: CodexNativeTurnKind,
): Promise<void> {
const isCompaction = kind === "compact";
const label = isCompaction ? "compaction" : "review";
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: params.pluginConfig });
const requestTimeoutMs = Math.min(
appServer.requestTimeoutMs,
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
);
await params.bindingStore.withLease(params.bindingIdentity, async () => {
const currentBinding = await params.bindingStore.read(params.bindingIdentity);
if (!currentBinding || !isSameNativeTurnBinding(currentBinding, params.expectedBinding)) {
throw new CodexNativeTurnBindingChangedError(
`Codex thread binding changed before native ${label}`,
);
}
const clientLease = await (params.clientLeaseFactory ?? leaseSharedCodexAppServerClient)({
startOptions: appServer.start,
authProfileId: params.authProfileId ?? currentBinding.authProfileId,
agentDir: params.agentDir,
config: params.config,
abandonSignal: params.abortSignal,
timeoutMs: appServer.requestTimeoutMs,
});
const client = clientLease.client;
let subscribedThreadId: string | undefined;
let abandonClient = false;
let lifecycleTransferred = false;
let awaitingNativeTurnStart = false;
const terminalTurnsBeforeWatch = new Set<string>();
let route: CodexThreadRouteReservation | undefined;
let completionWatch: CodexNativeTurnCompletionWatch | undefined;
let observedContextCompaction = false;
let bindingInvalidated = false;
let resolveNativeTurnStarted!: () => void;
const nativeTurnStarted = new Promise<void>((resolve) => {
resolveNativeTurnStarted = resolve;
});
try {
const router = getCodexAppServerTurnRouter(client);
route = router.reserveThread({
threadId: currentBinding.threadId,
onNotificationReceived: (notification, scope) => {
const contextCompactionStarted =
isCompaction &&
Boolean(scope.turnId) &&
notification.method === "item/started" &&
readCodexNotificationItem(notification.params)?.type === "contextCompaction";
if (contextCompactionStarted) {
observedContextCompaction = true;
}
if (!awaitingNativeTurnStart || !scope.turnId) {
return;
}
if (isCodexTerminalTurnNotification(notification)) {
terminalTurnsBeforeWatch.add(scope.turnId);
}
if (contextCompactionStarted) {
completionWatch ??= router.watchNativeTurnCompletion({
threadId: currentBinding.threadId,
turnId: scope.turnId,
timeoutMs: resolveCodexTurnTerminalIdleTimeoutMs(undefined),
});
resolveNativeTurnStarted();
}
},
onNotification: () => undefined,
});
throwIfCodexNativeTurnAborted(params.abortSignal, kind);
let resumed;
try {
subscribedThreadId = currentBinding.threadId;
resumed = await resumeCodexAppServerThread({
client,
abandonClient: clientLease.abandon,
request: {
threadId: currentBinding.threadId,
excludeTurns: true,
persistExtendedHistory: true,
},
timeoutMs: requestTimeoutMs,
signal: params.abortSignal,
});
} catch (error) {
abandonClient = isCodexAppServerUnsafeSubscriptionError(error);
throw error;
}
const invalidateNativeContextBinding = async () => {
if (bindingInvalidated) {
return;
}
const invalidated = await params.bindingStore.mutate(params.bindingIdentity, {
kind: "invalidate-native-context",
threadId: currentBinding.threadId,
...(isCompaction ? { invalidateContextEngineProjection: true as const } : {}),
});
if (!invalidated) {
throw new CodexNativeTurnBindingChangedError(
`Codex thread binding changed before native ${label}`,
);
}
bindingInvalidated = true;
};
if (isCompaction && observedContextCompaction) {
await invalidateNativeContextBinding();
}
if (resumed.thread.status?.type === "active") {
throw new Error(
`Codex thread already has an active turn; retry ${label} after it finishes`,
);
}
throwIfCodexNativeTurnAborted(params.abortSignal, kind);
await invalidateNativeContextBinding();
awaitingNativeTurnStart = true;
let requestResult: JsonValue | undefined;
try {
requestResult = await client.request(
isCompaction ? "thread/compact/start" : "review/start",
isCompaction
? { threadId: currentBinding.threadId }
: { threadId: currentBinding.threadId, target: { type: "uncommittedChanges" } },
{ timeoutMs: requestTimeoutMs },
);
} catch (error) {
const requestRejected = error instanceof CodexAppServerRpcError;
if (requestRejected) {
// A structured rejection proves this request did not start a native
// turn. Preserve only compaction already observed on the same thread.
completionWatch?.cancel();
completionWatch = undefined;
if (!isCompaction || !observedContextCompaction) {
const restored = await params.bindingStore.mutate(params.bindingIdentity, {
kind: "set",
binding: currentBinding,
});
if (!restored) {
throw new Error(`Codex thread binding changed after native ${label} was rejected`, {
cause: error,
});
}
}
throw error;
}
if (completionWatch) {
embeddedAgentLog.debug(`codex app-server ${kind} request failed after startup`, {
threadId: currentBinding.threadId,
error,
});
} else {
abandonClient = true;
throw error;
}
}
if (!isCompaction) {
try {
const review = assertCodexReviewStartResponse(requestResult);
if (review.reviewThreadId !== currentBinding.threadId) {
throw new Error(
`Codex review/start returned ${review.reviewThreadId} for inline review on ${currentBinding.threadId}`,
);
}
completionWatch = terminalTurnsBeforeWatch.has(review.turnId)
? { completion: Promise.resolve(true), cancel: () => undefined }
: router.watchNativeTurnCompletion({
threadId: currentBinding.threadId,
turnId: review.turnId,
timeoutMs: resolveCodexTurnTerminalIdleTimeoutMs(undefined),
});
} catch (error) {
abandonClient = true;
throw error;
}
} else if (!completionWatch) {
try {
await waitForCodexNativeTurnStart({
started: nativeTurnStarted,
routeSignal: route.signal,
timeoutMs: requestTimeoutMs,
threadId: currentBinding.threadId,
kind,
});
} catch (error) {
// Codex accepted Op::Compact, so missing startup confirmation is
// ambiguous. Keep facts invalidated and retire this connection.
abandonClient = true;
throw error;
}
}
awaitingNativeTurnStart = false;
route.release();
route = undefined;
const transferredWatch = completionWatch;
if (!transferredWatch) {
abandonClient = true;
throw new Error(
`codex app-server ${kind} turn started without a turn id for thread ${currentBinding.threadId}`,
);
}
completionWatch = undefined;
lifecycleTransferred = true;
monitorCodexNativeTurn({
completionWatch: transferredWatch,
clientLease,
subscribedThreadId,
threadId: currentBinding.threadId,
kind,
});
} finally {
if (!lifecycleTransferred) {
completionWatch?.cancel();
route?.release();
await settleCodexAppServerClientLease(clientLease, {
threadId: subscribedThreadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
abandon: abandonClient,
});
}
}
});
}
function assertCodexReviewStartResponse(value: JsonValue | undefined): {
turnId: string;
reviewThreadId: string;
} {
if (
!isJsonObject(value) ||
!isJsonObject(value.turn) ||
typeof value.turn.id !== "string" ||
!value.turn.id.trim() ||
typeof value.reviewThreadId !== "string" ||
!value.reviewThreadId.trim()
) {
throw new Error("invalid Codex review/start response");
}
return { turnId: value.turn.id, reviewThreadId: value.reviewThreadId };
}
function monitorCodexNativeTurn(params: {
completionWatch: CodexNativeTurnCompletionWatch;
clientLease: CodexAppServerClientLease;
subscribedThreadId?: string;
threadId: string;
kind: CodexNativeTurnKind;
}): void {
void (async () => {
const completed = await params.completionWatch.completion;
await settleCodexAppServerClientLease(params.clientLease, {
threadId: params.subscribedThreadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
abandon: !completed,
});
if (!completed) {
embeddedAgentLog.warn(`codex app-server ${params.kind} turn lost terminal confirmation`, {
threadId: params.threadId,
});
}
})().catch(async (error: unknown) => {
await params.clientLease.abandon().catch(() => undefined);
embeddedAgentLog.warn(`codex app-server ${params.kind} turn cleanup failed`, {
threadId: params.threadId,
error,
});
});
}
function throwIfCodexNativeTurnAborted(
signal: AbortSignal | undefined,
kind: CodexNativeTurnKind,
): void {
if (!signal?.aborted) {
return;
}
if (signal.reason instanceof Error) {
throw signal.reason;
}
throw new Error(`codex app-server ${kind} aborted before native turn startup`, {
cause: signal.reason,
});
}
async function waitForCodexNativeTurnStart(params: {
started: Promise<void>;
routeSignal: AbortSignal;
timeoutMs: number;
threadId: string;
kind: CodexNativeTurnKind;
}): Promise<void> {
const signal = params.routeSignal;
let removeAbort: (() => void) | undefined;
const aborted = new Promise<never>((_resolve, reject) => {
const onAbort = () => reject(asNativeTurnAbortError(signal));
signal.addEventListener("abort", onAbort, { once: true });
removeAbort = () => signal.removeEventListener("abort", onAbort);
if (signal.aborted) {
onAbort();
}
});
try {
await withTimeout(
Promise.race([params.started, aborted]),
params.timeoutMs,
`codex app-server ${params.kind} turn did not start for thread ${params.threadId}`,
);
} finally {
removeAbort?.();
}
}
function asNativeTurnAbortError(signal: AbortSignal): Error {
return signal.reason instanceof Error
? signal.reason
: new Error("codex app-server native turn startup aborted", { cause: signal.reason });
}
/**
* Starts native Codex compaction for a manually requested bound session, or
* reports why Codex-owned automatic compaction should handle the trigger.
*/
export async function maybeCompactCodexAppServerSession(
params: CompactEmbeddedAgentSessionParams,
options: CodexAppServerCompactOptions = {},
options: CodexAppServerCompactOptions,
): Promise<EmbeddedAgentCompactResult | undefined> {
warnIfIgnoringOpenClawCompactionOverrides(params);
// Codex owns automatic context-pressure compaction for Codex runtime sessions.
// This entry point starts native Codex compaction for the bound thread and
// returns immediately; Codex applies the compaction inside its app-server.
return compactCodexNativeThread(params, options);
}
function warnIfIgnoringOpenClawCompactionOverrides(
params: CompactEmbeddedAgentSessionParams,
): void {
const ignoredConfig = readIgnoredCompactionOverridePaths(params);
if (ignoredConfig.length === 0) {
return;
}
const warningKey = ignoredConfig.join("\0");
if (warnedIgnoredCompactionOverrides.has(warningKey)) {
return;
}
warnedIgnoredCompactionOverrides.add(warningKey);
embeddedAgentLog.warn(
"ignoring OpenClaw compaction overrides for Codex app-server compaction; Codex uses native server-side compaction",
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
ignoredConfig,
},
);
}
function readIgnoredCompactionOverridePaths(params: CompactEmbeddedAgentSessionParams): string[] {
const ignored = new Set<string>();
for (const entry of readCompactionOverrideEntries(params)) {
const localProvider =
typeof entry.record.provider === "string" ? entry.record.provider.trim() : "";
const inheritedProvider =
!localProvider && typeof entry.inheritedRecord?.provider === "string"
? entry.inheritedRecord.provider.trim()
: "";
const providerPath = localProvider
? `${entry.path}.compaction.provider`
: inheritedProvider && entry.inheritedPath
? `${entry.inheritedPath}.compaction.provider`
: undefined;
if (typeof entry.record.model === "string" && entry.record.model.trim()) {
ignored.add(`${entry.path}.compaction.model`);
}
if (providerPath) {
ignored.add(providerPath);
}
}
return [...ignored];
}
function readCompactionOverrideEntries(params: CompactEmbeddedAgentSessionParams): Array<{
path: string;
record: Record<string, unknown>;
inheritedRecord?: Record<string, unknown>;
inheritedPath?: string;
}> {
const entries: Array<{
path: string;
record: Record<string, unknown>;
inheritedRecord?: Record<string, unknown>;
inheritedPath?: string;
}> = [];
const defaultCompaction = readRecord(readRecord(params.config?.agents)?.defaults)?.compaction;
const defaultRecord = readRecord(defaultCompaction);
if (defaultRecord) {
entries.push({ path: "agents.defaults", record: defaultRecord });
}
const agentId = readAgentIdFromSessionKey(params.sessionKey ?? params.sandboxSessionKey);
if (!agentId) {
return entries;
}
const agents = Array.isArray(params.config?.agents?.list) ? params.config.agents.list : [];
const activeAgent = agents.find((agent) => {
const id = typeof agent?.id === "string" ? agent.id.trim().toLowerCase() : "";
return id === agentId;
});
const agentCompaction = readRecord(activeAgent)?.compaction;
const agentRecord = readRecord(agentCompaction);
if (agentRecord) {
entries.push({
path: `agents.list.${agentId}`,
record: agentRecord,
inheritedRecord: defaultRecord,
inheritedPath: "agents.defaults",
});
}
return entries;
}
function readAgentIdFromSessionKey(sessionKey: string | undefined): string | undefined {
const parts = sessionKey?.trim().toLowerCase().split(":").filter(Boolean) ?? [];
if (parts.length < 3 || parts[0] !== "agent") {
return undefined;
}
return parts[1]?.trim() || undefined;
}
function readRecord(value: unknown): Record<string, unknown> | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: undefined;
}
async function compactCodexNativeThread(
params: CompactEmbeddedAgentSessionParams,
options: CodexAppServerCompactOptions = {},
options: CodexAppServerCompactOptions,
): Promise<EmbeddedAgentCompactResult | undefined> {
if (params.trigger !== "manual" && !options.allowNonManualNativeRequest) {
embeddedAgentLog.info("skipping codex app-server compaction for non-manual trigger", {
@@ -172,6 +423,7 @@ async function compactCodexNativeThread(
}
const nativeExecutionBlock = resolveCodexNativeExecutionBlock({
config: params.config,
agentId: params.agentId,
sessionKey: params.sandboxSessionKey ?? params.sessionKey,
sessionId: params.sessionId,
surface: "native compaction",
@@ -179,17 +431,20 @@ async function compactCodexNativeThread(
if (nativeExecutionBlock) {
return { ok: false, compacted: false, reason: nativeExecutionBlock };
}
const appServer = resolveCodexAppServerRuntimeOptions({ pluginConfig: options.pluginConfig });
const initialBinding = await readCodexAppServerBinding(params.sessionFile, {
const bindingIdentity: CodexAppServerBindingIdentity = sessionBindingIdentity({
sessionId: params.sessionId,
sessionKey: params.sessionKey,
agentId: params.agentId,
config: params.config,
});
const initialBinding = await options.bindingStore.read(bindingIdentity);
if (!initialBinding?.threadId) {
return failedCodexThreadBindingCompactionResult(params, {
reason: "no codex app-server thread binding",
recovery: "missing_thread_binding",
});
}
let binding = initialBinding;
const binding = initialBinding;
const requestedAuthProfileId = params.authProfileId?.trim() || undefined;
if (
requestedAuthProfileId &&
@@ -200,85 +455,42 @@ async function compactCodexNativeThread(
// with another profile risks operating on a different Codex account.
return { ok: false, compacted: false, reason: "auth profile mismatch for session binding" };
}
const shouldReleaseDefaultLease = !options.clientFactory;
const clientFactory = options.clientFactory ?? defaultLeasedCodexAppServerClientFactory;
const client = await clientFactory(
appServer.start,
requestedAuthProfileId ?? binding.authProfileId,
params.agentDir,
params.config,
);
if (options.allowNonManualNativeRequest && params.abortSignal?.aborted) {
const currentBinding = await options.bindingStore.read(bindingIdentity);
return skippedCodexNativeCompactionResult(params, {
reason: "codex app-server compaction aborted before native compaction",
code: "aborted_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
});
}
try {
if (options.allowNonManualNativeRequest) {
const guardedResult = await withCodexAppServerBindingLock(params.sessionFile, async () => {
const currentBinding = await readCodexAppServerBinding(params.sessionFile, {
config: params.config,
});
if (params.abortSignal?.aborted) {
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server compaction aborted before native compaction",
code: "aborted_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
if (!currentBinding || !isSameNativeCompactionBinding(currentBinding, binding)) {
embeddedAgentLog.warn(
"skipping codex app-server compaction because the thread binding changed",
{
sessionId: params.sessionId,
sessionKey: params.sessionKey,
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
},
);
return {
started: false as const,
result: skippedCodexNativeCompactionResult(params, {
reason: "codex app-server binding changed before native compaction",
code: "binding_changed_before_native_compaction",
expectedThreadId: binding.threadId,
currentThreadId: currentBinding?.threadId,
}),
};
}
binding = currentBinding;
await clearContextEngineProjectionBeforeNativeCompaction({
sessionId: params.sessionId,
sessionFile: params.sessionFile,
binding,
config: params.config,
});
await client.request(
"thread/compact/start",
{
threadId: binding.threadId,
},
{
timeoutMs: Math.min(
appServer.requestTimeoutMs,
CODEX_APP_SERVER_BINDING_GUARDED_REQUEST_TIMEOUT_MS,
),
},
);
return { started: true as const };
});
if (!guardedResult.started) {
return guardedResult.result;
}
} else {
await client.request("thread/compact/start", {
threadId: binding.threadId,
});
}
await requestCodexNativeTurnForBinding(
{
bindingIdentity,
bindingStore: options.bindingStore,
expectedBinding: binding,
pluginConfig: options.pluginConfig,
authProfileId: requestedAuthProfileId,
agentDir: params.agentDir,
config: params.config,
abortSignal: params.abortSignal,
clientLeaseFactory: options.clientLeaseFactory,
},
"compact",
);
embeddedAgentLog.info("started codex app-server compaction", {
sessionId: params.sessionId,
threadId: binding.threadId,
});
} catch (error) {
if (
options.allowNonManualNativeRequest &&
error instanceof CodexNativeTurnBindingChangedError
) {
const latestBinding = await options.bindingStore.read(bindingIdentity);
return skippedBindingChangeResult(params, binding.threadId, latestBinding?.threadId);
}
if (isCodexThreadNotFoundError(error)) {
return failedCodexThreadBindingCompactionResult(params, {
threadId: binding.threadId,
@@ -297,10 +509,6 @@ async function compactCodexNativeThread(
compacted: false,
reason: formatCompactionError(error),
};
} finally {
if (shouldReleaseDefaultLease) {
releaseLeasedSharedCodexAppServerClient(client);
}
}
const resultDetails: JsonObject = {
backend: "codex-app-server",
@@ -326,6 +534,25 @@ async function compactCodexNativeThread(
};
}
function skippedBindingChangeResult(
params: CompactEmbeddedAgentSessionParams,
expectedThreadId: string,
currentThreadId: string | undefined,
): EmbeddedAgentCompactResult {
embeddedAgentLog.warn("skipping codex app-server compaction because the thread binding changed", {
sessionId: params.sessionId,
sessionKey: params.sessionKey,
expectedThreadId,
currentThreadId,
});
return skippedCodexNativeCompactionResult(params, {
reason: "codex app-server binding changed before native compaction",
code: "binding_changed_before_native_compaction",
expectedThreadId,
currentThreadId,
});
}
function skippedCodexNativeCompactionResult(
params: CompactEmbeddedAgentSessionParams,
skipped: {
@@ -382,39 +609,7 @@ function failedCodexThreadBindingCompactionResult(
};
}
async function clearContextEngineProjectionBeforeNativeCompaction(params: {
sessionId: string;
sessionFile: string;
binding: CodexAppServerThreadBinding;
config: CompactEmbeddedAgentSessionParams["config"];
}): Promise<void> {
const contextEngineBinding = params.binding.contextEngine;
if (!contextEngineBinding?.projection) {
return;
}
// Native Codex compaction mutates the thread history outside the projection
// guard. Clear only the projection marker so the next turn reprojects context.
await writeCodexAppServerBinding(
params.sessionFile,
{
...params.binding,
contextEngine: {
...contextEngineBinding,
projection: undefined,
},
createdAt: params.binding.createdAt,
},
{ config: params.config },
);
embeddedAgentLog.info("cleared codex context-engine projection before native compaction", {
sessionId: params.sessionId,
threadId: params.binding.threadId,
previousEpoch: contextEngineBinding.projection.epoch,
previousFingerprint: contextEngineBinding.projection.fingerprint,
});
}
function isSameNativeCompactionBinding(
function isSameNativeTurnBinding(
current: CodexAppServerThreadBinding,
expected: CodexAppServerThreadBinding,
): boolean {

View File

@@ -1,5 +1,7 @@
// Codex tests cover config plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { describe, expect, it, vi } from "vitest";
import {
@@ -200,7 +202,7 @@ describe("Codex app-server config", () => {
},
unix_sockets: {
"/tmp/mock-proxy.sock": "allow",
"/tmp/blocked.sock": "none",
"/tmp/blocked.sock": "deny",
},
proxy_url: "http://127.0.0.1:3128",
socks_url: "socks5h://127.0.0.1:8081",
@@ -558,7 +560,6 @@ describe("Codex app-server config", () => {
const switchedLocalModel = resolveCodexModelBackedReviewerPolicyContext({
model: "lmstudio/local-model",
bindingModel: "gpt-5.5",
nativeAuthProfile: true,
});
expect(switchedLocalModel).toEqual({
modelProvider: "lmstudio",
@@ -745,6 +746,39 @@ describe("Codex app-server config", () => {
});
});
it("reloads Codex config.toml policy when Codex can reload it", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-config-"));
const codexHome = path.join(agentDir, "codex-home");
const configPath = path.join(codexHome, "config.toml");
await fs.mkdir(codexHome);
try {
await fs.writeFile(configPath, 'openai_base_url = "http://localhost:8080/v1"\n');
const context = { modelProvider: "openai", model: "gpt-5.5", agentDir };
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(false);
await fs.writeFile(configPath, 'openai_base_url = "https://api.openai.com/v1"\n');
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(true);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("observes a Codex config.toml created after the first policy check", async () => {
const agentDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-config-"));
const codexHome = path.join(agentDir, "codex-home");
const configPath = path.join(codexHome, "config.toml");
await fs.mkdir(codexHome);
try {
const context = { modelProvider: "openai", model: "gpt-5.5", agentDir };
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(true);
await fs.writeFile(configPath, 'openai_base_url = "http://localhost:8080/v1"\n');
expect(canUseCodexModelBackedApprovalsReviewerForModel(context)).toBe(false);
} finally {
await fs.rm(agentDir, { recursive: true, force: true });
}
});
it("forces prompting when explicit no-prompt config cannot use model-backed review", () => {
const runtime = resolveRuntimeForTest({
pluginConfig: {
@@ -942,8 +976,8 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
env: {},
modelProvider: "openai",
requirementsPath: "/custom/codex/requirements.toml",
readRequirementsFile: (path) => {
readPaths.push(path);
readRequirementsFile: (requirementsPath) => {
readPaths.push(requirementsPath);
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
},
});
@@ -963,8 +997,8 @@ allowed_sandbox_modes = ["read-only", "workspace-write"]
env: { ProgramData: "D:\\ManagedData" },
modelProvider: "openai",
platform: "win32",
readRequirementsFile: (path) => {
readPaths.push(path);
readRequirementsFile: (requirementsPath) => {
readPaths.push(requirementsPath);
return 'allowed_sandbox_modes = ["read-only", "workspace-write"]\n';
},
});

View File

@@ -192,6 +192,11 @@ export type CodexAppServerRuntimeOptions = {
networkProxy?: ResolvedCodexAppServerNetworkProxyConfig;
};
export type CodexAppServerRuntimeResolution = {
appServer: CodexAppServerRuntimeOptions;
modelBackedReviewerAvailable: boolean;
};
export type CodexModelBackedReviewerContext = {
modelProvider?: string;
model?: string;
@@ -332,7 +337,9 @@ const codexAppServerNetworkProxySchema = z
baseProfile: z.enum(["read-only", "workspace"]).optional(),
mode: z.enum(["limited", "full"]).optional(),
domains: z.record(z.string(), codexAppServerNetworkProxyDomainPermissionSchema).optional(),
unixSockets: z.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema).optional(),
unixSockets: z
.record(z.string(), codexAppServerNetworkProxyUnixSocketPermissionSchema)
.optional(),
proxyUrl: z.string().trim().min(1).optional(),
socksUrl: z.string().trim().min(1).optional(),
enableSocks5: z.boolean().optional(),
@@ -501,25 +508,34 @@ function resolveCodexPluginDestructivePolicy(policy: CodexPluginDestructivePolic
};
}
type CodexAppServerRuntimeParams = {
pluginConfig?: unknown;
execMode?: OpenClawExecMode;
execPolicy?: OpenClawExecPolicyForCodexAppServer;
modelProvider?: string;
model?: string;
config?: ProviderAuthAliasConfig;
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
requirementsToml?: string | null;
requirementsPath?: string;
readRequirementsFile?: (path: string) => string | undefined;
platform?: NodeJS.Platform;
hostName?: string;
openClawSandboxActive?: boolean;
};
export function resolveCodexAppServerRuntimeOptions(
params: {
pluginConfig?: unknown;
execMode?: OpenClawExecMode;
execPolicy?: OpenClawExecPolicyForCodexAppServer;
modelProvider?: string;
model?: string;
config?: ProviderAuthAliasConfig;
env?: NodeJS.ProcessEnv;
agentDir?: string;
codexConfigToml?: string | null;
requirementsToml?: string | null;
requirementsPath?: string;
readRequirementsFile?: (path: string) => string | undefined;
platform?: NodeJS.Platform;
hostName?: string;
openClawSandboxActive?: boolean;
} = {},
params: CodexAppServerRuntimeParams = {},
): CodexAppServerRuntimeOptions {
return resolveCodexAppServerRuntime(params).appServer;
}
/** Resolves runtime options and the model-policy fact computed with them. */
export function resolveCodexAppServerRuntime(
params: CodexAppServerRuntimeParams = {},
): CodexAppServerRuntimeResolution {
const env = params.env ?? process.env;
const config = readCodexPluginConfig(params.pluginConfig).appServer ?? {};
const transport = resolveTransport(config.transport);
@@ -659,43 +675,46 @@ export function resolveCodexAppServerRuntimeOptions(
: "implicit";
return {
start: {
transport,
command,
commandSource,
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
...(url ? { url } : {}),
...(authToken ? { authToken } : {}),
headers,
...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}),
modelBackedReviewerAvailable: canUseModelBackedReviewer,
appServer: {
start: {
transport,
command,
commandSource,
args: args.length > 0 ? args : ["app-server", "--listen", "stdio://"],
...(url ? { url } : {}),
...(authToken ? { authToken } : {}),
headers,
...(transport === "stdio" && clearEnv.length > 0 ? { clearEnv } : {}),
},
connectionClass,
remoteAppsSubstrate,
...(remoteWorkspaceRoot ? { remoteWorkspaceRoot } : {}),
codeModeOnly: config.codeModeOnly === true,
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
turnCompletionIdleTimeoutMs: normalizePositiveNumber(
config.turnCompletionIdleTimeoutMs,
60_000,
),
...(config.postToolRawAssistantCompletionIdleTimeoutMs !== undefined
? {
postToolRawAssistantCompletionIdleTimeoutMs: normalizePositiveNumber(
config.postToolRawAssistantCompletionIdleTimeoutMs,
60_000,
),
}
: {}),
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
approvalPolicySource,
sandbox: resolvedSandbox,
approvalsReviewer:
forcedPolicy?.approvalsReviewer ??
explicitApprovalsReviewer ??
defaultPolicy?.approvalsReviewer ??
(policyMode === "guardian" ? "auto_review" : "user"),
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
...(serviceTier ? { serviceTier } : {}),
},
connectionClass,
remoteAppsSubstrate,
...(remoteWorkspaceRoot ? { remoteWorkspaceRoot } : {}),
codeModeOnly: config.codeModeOnly === true,
requestTimeoutMs: normalizePositiveNumber(config.requestTimeoutMs, 60_000),
turnCompletionIdleTimeoutMs: normalizePositiveNumber(
config.turnCompletionIdleTimeoutMs,
60_000,
),
...(config.postToolRawAssistantCompletionIdleTimeoutMs !== undefined
? {
postToolRawAssistantCompletionIdleTimeoutMs: normalizePositiveNumber(
config.postToolRawAssistantCompletionIdleTimeoutMs,
60_000,
),
}
: {}),
approvalPolicy: forcedPolicy?.approvalPolicy ?? approvalPolicy,
approvalPolicySource,
sandbox: resolvedSandbox,
approvalsReviewer:
forcedPolicy?.approvalsReviewer ??
explicitApprovalsReviewer ??
defaultPolicy?.approvalsReviewer ??
(policyMode === "guardian" ? "auto_review" : "user"),
...(serviceTier ? { serviceTier } : {}),
...resolveCodexAppServerNetworkProxy(config.networkProxy, resolvedSandbox),
};
}
@@ -767,7 +786,6 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
model?: string;
bindingModelProvider?: string;
bindingModel?: string;
nativeAuthProfile?: boolean;
}): CodexModelBackedReviewerContext {
const provider = params.provider?.trim();
if (provider && provider.toLowerCase() !== "codex") {
@@ -799,7 +817,7 @@ export function resolveCodexModelBackedReviewerPolicyContext(params: {
};
}
return {
modelProvider: params.nativeAuthProfile === true ? "openai" : undefined,
modelProvider: undefined,
model: params.model ?? params.bindingModel,
};
}
@@ -866,6 +884,7 @@ export function codexAppServerStartOptionsKey(
options: CodexAppServerStartOptions,
params: {
authProfileId?: string;
authAccountCacheKey?: string;
agentDir?: string;
fallbackApiKeyCacheKey?: string;
} = {},
@@ -885,6 +904,7 @@ export function codexAppServerStartOptionsKey(
.map(([key, value]) => [key, hashSecretForKey(value, `env:${key}`)]),
clearEnv: [...(options.clearEnv ?? [])].toSorted(),
authProfileId: params.authProfileId ?? null,
authAccountCacheKey: params.authAccountCacheKey ?? null,
agentDir: params.agentDir ?? null,
fallbackApiKeyCacheKey: params.fallbackApiKeyCacheKey ?? null,
});
@@ -924,7 +944,7 @@ function resolveCodexAppServerNetworkProxy(
enabled: true,
mode: config.mode,
domains: normalizeNetworkProxyPermissionMap(config.domains),
unix_sockets: normalizeNetworkProxyPermissionMap(config.unixSockets),
unix_sockets: normalizeNetworkProxyUnixSocketPermissionMap(config.unixSockets),
proxy_url: readNonEmptyString(config.proxyUrl),
socks_url: readNonEmptyString(config.socksUrl),
enable_socks5: config.enableSocks5,
@@ -979,6 +999,20 @@ export function fingerprintCodexAppServerNetworkProxyConfigPatch(configPatch: Js
return createHash("sha256").update(stableStringifyJson(configPatch)).digest("hex");
}
function normalizeNetworkProxyUnixSocketPermissionMap(
value: Record<string, CodexAppServerNetworkProxyUnixSocketPermission> | undefined,
): Record<string, "allow" | "deny"> | undefined {
const normalized = normalizeNetworkProxyPermissionMap(value);
return normalized
? Object.fromEntries(
Object.entries(normalized).map(([socketPath, permission]) => [
socketPath,
permission === "none" ? "deny" : permission,
]),
)
: undefined;
}
function normalizeNetworkProxyPermissionMap<TPermission extends string>(
value: Record<string, TPermission> | undefined,
): Record<string, TPermission> | undefined {

View File

@@ -249,10 +249,64 @@ describe("projectContextEngineAssemblyForCodex", () => {
// The user's actual request is the priority tail and must survive truncation.
expect(fitted).toContain("Current user request:");
expect(fitted.endsWith("q".repeat(40))).toBe(true);
// The dropped older context is reported, not silently lost.
// Current context still survives even when an earlier projection is dropped.
expect(fitted).toContain("older context");
// The dropped older content is reported, not silently lost.
expect(fitted).toContain("[truncated ");
});
it("keeps the current request and fitting hook context after projecting history", () => {
const before = "OpenClaw assembled context for this turn:\n<conversation_context>\n";
const context = `recent context ${"c".repeat(800)}`;
const request = "\n</conversation_context>\n\nCurrent user request:\nkeep this request";
const hookAppend = "\n\nhook context survives";
const promptText = `${before}${context}${request}${hookAppend}`;
const maxChars = 420;
const fitted = fitCodexProjectedContextForTurnStart({
promptText,
contextRange: { start: before.length, end: before.length + context.length },
requestRange: {
start: before.length + context.length,
end: before.length + context.length + request.length,
},
maxChars,
});
expect(fitted.length).toBeLessThanOrEqual(maxChars);
expect(fitted).toContain("[truncated ");
expect(fitted).toContain("Current user request:\nkeep this request");
expect(fitted).toContain("hook context survives");
});
it("keeps the original input when a hook appends context without a projection", () => {
const prompt = "current prompt survives";
const hookAppend = `\n\nhook context ${"h".repeat(800)}`;
const maxChars = 420;
const fitted = fitCodexProjectedContextForTurnStart({
promptText: `${prompt}${hookAppend}`,
preservedRange: { start: 0, end: prompt.length },
maxChars,
});
expect(fitted.length).toBeLessThanOrEqual(maxChars);
expect(fitted).toContain(prompt);
expect(fitted).not.toContain("hook context");
});
it("bounds hook output for an empty original input", () => {
const maxChars = 420;
const fitted = fitCodexProjectedContextForTurnStart({
promptText: `hook context ${"h".repeat(800)} hook tail`,
preservedRange: { start: 0, end: 0 },
maxChars,
});
expect(fitted.length).toBeLessThanOrEqual(maxChars);
expect(fitted).toContain("hook tail");
});
it("bounds output for a large request under the default Codex turn limit", () => {
const maxChars = CODEX_TURN_START_TEXT_INPUT_MAX_CHARS;
// A large assembled header prefix already over the cap forces the

View File

@@ -121,6 +121,8 @@ export function resolveCodexContextEngineProjectionReserveTokens(params: {
export function fitCodexProjectedContextForTurnStart(params: {
promptText: string;
contextRange?: CodexProjectedContextRange;
requestRange?: CodexProjectedContextRange;
preservedRange?: CodexProjectedContextRange;
maxChars?: number;
}): string {
const maxChars =
@@ -132,23 +134,63 @@ export function fitCodexProjectedContextForTurnStart(params: {
}
const range = normalizeProjectedContextRange(params.contextRange, params.promptText.length);
if (!range) {
return params.promptText;
const preservedRange = normalizeProjectedContextRange(
params.preservedRange,
params.promptText.length,
);
if (!preservedRange) {
return params.promptText;
}
const preservedText = params.promptText.slice(preservedRange.start, preservedRange.end);
if (!preservedText) {
return truncateOlderContext(params.promptText, maxChars);
}
if (preservedText.length >= maxChars) {
return truncateOlderContext(preservedText, maxChars);
}
const beforeRange = params.promptText.slice(0, preservedRange.start);
return `${truncateOlderContext(beforeRange, maxChars - preservedText.length)}${preservedText}`;
}
const beforeContext = params.promptText.slice(0, range.start);
const context = params.promptText.slice(range.start, range.end);
const afterContext = params.promptText.slice(range.end);
const requestRange = normalizeProjectedContextRange(
params.requestRange,
params.promptText.length,
);
if (
requestRange &&
requestRange.start >= range.end &&
requestRange.end < params.promptText.length
) {
const request = params.promptText.slice(requestRange.start, requestRange.end);
if (request.length >= maxChars) {
return truncateOlderContext(request, maxChars);
}
const appendedContext = params.promptText.slice(requestRange.end);
// Hook-appended context is newer than the projected history. Retain it
// before trimming the projection, while the full current request remains
// the hard boundary that must survive a bounded turn/start input.
const fittedAppendedContext = truncateOlderContext(appendedContext, maxChars - request.length);
const contextBudget = maxChars - request.length - fittedAppendedContext.length;
const fittedContext = truncateOlderContext(context, contextBudget);
const beforeContextBudget =
maxChars - fittedContext.length - request.length - fittedAppendedContext.length;
return `${truncateOlderContext(beforeContext, beforeContextBudget)}${fittedContext}${request}${fittedAppendedContext}`;
}
const contextBudget = maxChars - beforeContext.length - afterContext.length;
if (contextBudget > 0) {
const fittedContext = truncateOlderContext(context, contextBudget);
return `${beforeContext}${fittedContext}${afterContext}`;
}
// The header plus the trailing user request already fill the limit, so the
// older context drops entirely and the remaining text must still be bounded;
// otherwise Codex app-server rejects the turn for exceeding
// MAX_USER_INPUT_TEXT_CHARS. truncateOlderContext keeps the tail, preserving
// the user's actual request over the older header text.
return truncateOlderContext(`${beforeContext}${afterContext}`, maxChars);
// Hook-added prefixes can make the non-context text exceed the limit. Keep
// the current context tail before the user's request; dropping it would make
// a duplicated earlier projection crowd out the newest assembled context.
const afterContextText = truncateOlderContext(afterContext, maxChars);
const contextBudgetAfterRequest = maxChars - afterContextText.length;
const fittedContext = truncateOlderContext(context, contextBudgetAfterRequest);
return `${fittedContext}${afterContextText}`;
}
function normalizeProjectedContextRange(

View File

@@ -11,11 +11,10 @@ import {
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
addSandboxShellDynamicToolsIfAvailable,
buildDynamicTools,
filterCodexDynamicToolsForAllowlist,
hasWildcardCodexToolsAllow,
includeForcedCodexDynamicToolAllow,
mapCodexAppServerRemoteWorkspacePath,
prepareDynamicToolCatalog,
resetOpenClawCodingToolsFactoryForTests,
resolveCodexAppServerExecutionCwd,
resolveOpenClawCodingToolsSessionKeys,
@@ -23,6 +22,7 @@ import {
setOpenClawCodingToolsFactoryForTests,
shouldEnableCodexAppServerNativeToolSurface,
shouldForceMessageTool,
type OpenClawCodingToolsFactory,
} from "./dynamic-tool-build.js";
import {
filterCodexDynamicTools,
@@ -106,13 +106,13 @@ function createRuntimeDynamicTool(name: string): RuntimeDynamicToolForTest {
async function buildDynamicToolsForTest(
params: EmbeddedRunAttemptParams,
workspaceDir: string,
options: Partial<Parameters<typeof buildDynamicTools>[0]> = {},
options: Partial<Parameters<typeof prepareDynamicToolCatalog>[0]> = {},
) {
const sandboxSessionKey = params.sessionKey;
if (!sandboxSessionKey) {
throw new Error("createParams must provide a sessionKey for Codex dynamic tool tests.");
}
return buildDynamicTools({
const catalog = await prepareDynamicToolCatalog({
params,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: workspaceDir,
@@ -125,6 +125,7 @@ async function buildDynamicToolsForTest(
onYieldDetected: () => undefined,
...options,
});
return catalog.tools;
}
describe("Codex app-server dynamic tool build", () => {
@@ -227,197 +228,51 @@ describe("Codex app-server dynamic tool build", () => {
]);
});
it("removes managed web_search when domain-restricted Codex hosted search is active", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.config = {
tools: {
web: {
search: { openaiCodex: { allowedDomains: ["example.com"] } },
},
},
} as never;
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
it("prepares runtime and durable tool views from one OpenClaw catalog", async () => {
const messageTool = createRuntimeDynamicTool("message");
const webSearchTool = createRuntimeDynamicTool("web_search");
const heartbeatTool = createRuntimeDynamicTool("heartbeat_respond");
const factory = vi.fn<OpenClawCodingToolsFactory>((options) => [
messageTool,
webSearchTool,
...(options?.enableHeartbeatTool ? [heartbeatTool] : []),
]);
let webSearchAllowed = false;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(webSearchAllowed).toBe(true);
});
it("reports hosted search denied when effective tool policy removes web_search", async () => {
setOpenClawCodingToolsFactoryForTests(factory);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
const params = createParams(sessionFile, workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let webSearchAllowed = true;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(webSearchAllowed).toBe(false);
});
it("separates persistent search policy from a runtime toolsAllow restriction", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.toolsAllow = ["message"];
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
]);
let persistentWebSearchAllowed = false;
let webSearchAllowed = true;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(persistentWebSearchAllowed).toBe(true);
expect(webSearchAllowed).toBe(false);
});
it("keeps persistent search denied when runtime toolsAllow also excludes it", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.toolsAllow = ["message"];
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let persistentWebSearchAllowed = true;
let webSearchAllowed = true;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(persistentWebSearchAllowed).toBe(false);
expect(webSearchAllowed).toBe(false);
});
it("treats sender-scoped web_search denial as transient", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.senderId = "restricted-sender";
params.config = {
const runtimePlan = createCodexRuntimePlanFixture();
params.runtimePlan = {
...runtimePlan,
tools: {
toolsBySender: {
"id:restricted-sender": { deny: ["web_search"] },
},
normalize: (tools: Array<{ name: string }>) =>
tools.filter((tool) => tool.name === "message"),
logDiagnostics: () => undefined,
},
} as never;
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let persistentWebSearchAllowed = false;
let webSearchAllowed = true;
} as unknown as NonNullable<EmbeddedRunAttemptParams["runtimePlan"]>;
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
onWebSearchPolicyResolved: (allowed) => {
webSearchAllowed = allowed;
},
const catalog = await prepareDynamicToolCatalog({
params,
resolvedWorkspace: workspaceDir,
effectiveWorkspace: workspaceDir,
sandboxSessionKey: params.sessionKey ?? "agent:main:session-1",
sandbox: { enabled: false, backendId: "docker" } as never,
nativeToolSurfaceEnabled: true,
runAbortController: new AbortController(),
sessionAgentId: "main",
pluginConfig: {},
onYieldDetected: () => undefined,
});
expect(tools.map((tool) => tool.name)).toEqual(["message"]);
expect(persistentWebSearchAllowed).toBe(true);
expect(webSearchAllowed).toBe(false);
});
it("keeps persistent search denied when global and sender policy both deny it", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.senderId = "restricted-sender";
params.config = {
tools: {
deny: ["web_search"],
toolsBySender: {
"id:restricted-sender": { deny: ["web_search"] },
},
},
} as never;
setOpenClawCodingToolsFactoryForTests(() => [createRuntimeDynamicTool("message")]);
let persistentWebSearchAllowed = true;
await buildDynamicToolsForTest(params, workspaceDir, {
onPersistentWebSearchPolicyResolved: (allowed) => {
persistentWebSearchAllowed = allowed;
},
});
expect(persistentWebSearchAllowed).toBe(false);
});
it("keeps managed web_search when a managed provider is explicitly selected", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
params.config = {
tools: {
web: {
search: { provider: "brave" },
},
},
} as never;
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
expect(factory).toHaveBeenCalledTimes(1);
expect(factory.mock.calls[0]?.[0]?.enableHeartbeatTool).toBe(true);
expect(catalog.tools.map((tool) => tool.name)).toEqual(["message"]);
expect(catalog.registeredTools.map((tool) => tool.name)).toEqual([
"message",
"web_search",
"heartbeat_respond",
]);
const tools = await buildDynamicToolsForTest(params, workspaceDir);
expect(tools.map((tool) => tool.name)).toEqual(["web_search", "message"]);
});
it("keeps managed web_search when the active Codex provider lacks hosted search", async () => {
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(path.join(tempDir, "session.jsonl"), workspaceDir);
params.disableTools = false;
params.runtimePlan = createCodexRuntimePlanFixture();
setOpenClawCodingToolsFactoryForTests(() => [
createRuntimeDynamicTool("web_search"),
createRuntimeDynamicTool("message"),
]);
const tools = await buildDynamicToolsForTest(params, workspaceDir, {
nativeProviderWebSearchSupport: "unsupported",
});
expect(tools.map((tool) => tool.name)).toEqual(["web_search", "message"]);
});
it("applies additional Codex dynamic tool excludes without exposing Codex-native tools", () => {

View File

@@ -46,6 +46,9 @@ type OpenClawExecOptions = NonNullable<OpenClawCodingToolsOptions["exec"]>;
export type OpenClawCodingToolsFactory =
(typeof import("openclaw/plugin-sdk/agent-harness"))["createOpenClawCodingTools"];
type OpenClawDynamicTool = ReturnType<OpenClawCodingToolsFactory>[number];
type OpenClawDynamicToolProjection = ReturnType<
typeof filterProviderNormalizableTools<OpenClawDynamicTool>
>;
type OpenClawSandboxContext = Awaited<ReturnType<typeof resolveSandboxContext>>;
type CodexDynamicToolBuildEvent = Parameters<
NonNullable<EmbeddedRunAttemptParams["onAgentEvent"]>
@@ -60,9 +63,7 @@ const CODEX_NATIVE_SANDBOX_TOOL_REQUIREMENTS = [
"apply_patch",
] as const;
const CODEX_MEMORY_FLUSH_DYNAMIC_TOOL_ALLOW = new Set(["read", "write"]);
const CODEX_NODE_EXEC_DYNAMIC_TOOL_NAME = "node_exec";
const CODEX_NODE_PROCESS_DYNAMIC_TOOL_NAME = "node_process";
const CODEX_NODE_EXEC_HIDDEN_PARAMETER_NAMES = new Set(["host", "security", "ask", "node"]);
const CODEX_HEARTBEAT_DYNAMIC_TOOL_NAME = "heartbeat_respond";
/** Runtime inputs needed to derive the exact Codex dynamic tool surface for a turn. */
export type DynamicToolBuildParams = {
@@ -78,9 +79,6 @@ export type DynamicToolBuildParams = {
sessionAgentId: string;
pluginConfig: CodexPluginConfig;
profilerEnabled?: boolean;
forceHeartbeatTool?: boolean;
ignoreDisableMessageTool?: boolean;
ignoreRuntimePlan?: boolean;
onYieldDetected: () => void;
onCodexAppServerEvent?: (event: CodexDynamicToolBuildEvent) => void;
onPersistentWebSearchPolicyResolved?: (allowed: boolean) => void;
@@ -143,6 +141,11 @@ type CodexDynamicToolBuildStageSummary = {
stages: CodexDynamicToolBuildStageTiming[];
};
type CodexDynamicToolBuildStageTracker = {
mark: (name: string) => void;
snapshot: () => CodexDynamicToolBuildStageSummary;
};
const CODEX_DYNAMIC_TOOL_BUILD_WARN_TOTAL_MS = 1_000;
const CODEX_DYNAMIC_TOOL_BUILD_WARN_STAGE_MS = 500;
@@ -204,26 +207,42 @@ export function formatCodexDynamicToolBuildStageSummary(
: "none";
}
/** Builds, filters, and normalizes Codex-compatible runtime tools for a single turn. */
export async function buildDynamicTools(input: DynamicToolBuildParams) {
/** Builds the turn-visible and durable registration views from one OpenClaw tool catalog. */
export async function prepareDynamicToolCatalog(input: DynamicToolBuildParams): Promise<{
tools: OpenClawDynamicTool[];
registeredTools: OpenClawDynamicTool[];
}> {
const { params } = input;
const messagePolicyParams = input.ignoreDisableMessageTool
? { ...params, disableMessageTool: false }
: params;
if (params.disableTools) {
input.onWebSearchPolicyResolved?.(false);
return [];
if (params.disableTools || !supportsModelTools(params.model)) {
return { tools: [], registeredTools: [] };
}
if (!supportsModelTools(params.model)) {
input.onPersistentWebSearchPolicyResolved?.(false);
input.onWebSearchPolicyResolved?.(false);
return [];
}
// Dynamic tool construction is on the reply hot path, so per-stage
// Date.now/span bookkeeping runs only when the Codex profiler flag is set.
const toolBuildStages = createCodexDynamicToolBuildStageTracker({
enabled: input.profilerEnabled,
});
// The durable schema must include heartbeat_respond across normal and heartbeat
// turns. Build that superset once, then hide it only from normal turn exposure.
const allTools = await buildOpenClawDynamicToolSource(input, toolBuildStages);
const readableTools = filterProviderNormalizableTools(allTools);
toolBuildStages.mark("provider-normalization");
const tools = projectDynamicTools(input, readableTools, toolBuildStages, {
excludeHeartbeatTool: params.trigger !== "heartbeat",
phase: "runtime-tools",
stagePrefix: "runtime",
});
const registeredTools = projectDynamicTools(input, readableTools, toolBuildStages, {
ignoreRuntimePlan: true,
phase: "registered-tools",
reportDiagnostics: false,
stagePrefix: "registered",
});
return { tools, registeredTools };
}
async function buildOpenClawDynamicToolSource(
input: DynamicToolBuildParams,
toolBuildStages: CodexDynamicToolBuildStageTracker,
): Promise<OpenClawDynamicTool[]> {
const { params } = input;
const modelHasVision = params.model.input?.includes("image") ?? false;
const agentDir = params.agentDir ?? resolveAgentDir(params.config ?? {}, input.sessionAgentId);
const agentHarness = await import("openclaw/plugin-sdk/agent-harness");
@@ -302,10 +321,10 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
requireExplicitMessageTarget:
params.requireExplicitMessageTarget ?? isSubagentSessionKey(params.sessionKey),
sourceReplyDeliveryMode: params.sourceReplyDeliveryMode,
disableMessageTool: input.ignoreDisableMessageTool ? false : params.disableMessageTool,
forceMessageTool: shouldForceMessageTool(messagePolicyParams),
enableHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
forceHeartbeatTool: params.trigger === "heartbeat" || input.forceHeartbeatTool === true,
disableMessageTool: params.disableMessageTool,
forceMessageTool: shouldForceMessageTool(params),
enableHeartbeatTool: true,
forceHeartbeatTool: true,
onYield: (message) => {
input.onYieldDetected();
input.onCodexAppServerEvent?.({
@@ -320,16 +339,30 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
allocateToolOutcomeOrdinal: params.allocateToolOutcomeOrdinal,
});
toolBuildStages.mark("create-openclaw-coding-tools");
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [];
const readableAllToolProjection = filterProviderNormalizableTools(allTools);
preNormalizationDiagnostics.push(...readableAllToolProjection.diagnostics);
const webSearchPlan = resolveCodexWebSearchPlan({
config: params.config,
disableTools: params.disableTools,
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled,
nativeProviderWebSearchSupport: input.nativeProviderWebSearchSupport,
});
const readableAllTools = [...readableAllToolProjection.tools];
return allTools;
}
function projectDynamicTools(
input: DynamicToolBuildParams,
source: OpenClawDynamicToolProjection,
toolBuildStages: CodexDynamicToolBuildStageTracker,
options: {
excludeHeartbeatTool?: boolean;
ignoreRuntimePlan?: boolean;
phase?: "runtime-tools" | "registered-tools";
reportDiagnostics?: boolean;
stagePrefix?: string;
} = {},
): OpenClawDynamicTool[] {
const { params } = input;
const markStage = (name: string) =>
toolBuildStages.mark(options.stagePrefix ? `${options.stagePrefix}-${name}` : name);
const preNormalizationDiagnostics: RuntimeToolSchemaDiagnostic[] = [...source.diagnostics];
const readableAllTools = [...source.tools].filter(
(tool) =>
!options.excludeHeartbeatTool ||
normalizeCodexDynamicToolName(tool.name) !== CODEX_HEARTBEAT_DYNAMIC_TOOL_NAME,
);
const codexFilteredTools = addNodeShellDynamicToolsIfNeeded(
addSandboxShellDynamicToolsIfAvailable(
isCodexMemoryFlushRun(params)
@@ -342,51 +375,18 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
input,
nativeExecutionPolicy,
);
toolBuildStages.mark("codex-filtering");
markStage("codex-filtering");
const modelHasVision = params.model.input?.includes("image") ?? false;
const visionFilteredTools = filterToolsForVisionInputs(codexFilteredTools, {
modelHasVision,
hasInboundImages: (params.images?.length ?? 0) > 0,
});
toolBuildStages.mark("vision-filtering");
const webSearchPresent = visionFilteredTools.some((tool) => tool.name === "web_search");
const webSearchPolicy = agentHarness.resolveWebSearchToolPolicy({
config: params.config,
modelProvider: params.model.provider,
modelId: params.modelId,
agentId: input.sessionAgentId,
sessionKey: input.sandboxSessionKey,
sandboxToolPolicy: input.sandbox?.tools,
messageProvider: resolveCodexMessageToolProvider(params),
agentAccountId: params.agentAccountId,
groupId: params.groupId,
groupChannel: params.groupChannel,
groupSpace: params.groupSpace,
spawnedBy: params.spawnedBy,
senderId: params.senderId,
senderName: params.senderName,
senderUsername: params.senderUsername,
senderE164: params.senderE164,
});
const senderScopedWebSearchRestriction =
!webSearchPolicy.allowed && webSearchPolicy.persistentAllowed;
const transientWebSearchRestriction =
senderScopedWebSearchRestriction || isCodexMemoryFlushRun(params);
const persistentCodexWebSearchSurface =
params.config?.tools?.web?.search?.enabled !== false &&
!(input.pluginConfig.codexDynamicToolsExclude ?? []).some(
(name) => normalizeCodexDynamicToolName(name) === "web_search",
);
input.onPersistentWebSearchPolicyResolved?.(
webSearchPresent ||
(persistentCodexWebSearchSurface &&
transientWebSearchRestriction &&
webSearchPolicy.persistentAllowed),
);
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, messagePolicyParams);
markStage("vision-filtering");
const toolsAllow = includeForcedCodexDynamicToolAllow(params.toolsAllow, params);
const filteredTools = filterCodexDynamicToolsForAllowlist(visionFilteredTools, toolsAllow);
toolBuildStages.mark("allowlist-filter");
markStage("allowlist-filter");
const normalizedTools = normalizeAgentRuntimeTools({
runtimePlan: input.ignoreRuntimePlan ? undefined : params.runtimePlan,
runtimePlan: options.ignoreRuntimePlan ? undefined : params.runtimePlan,
tools: filteredTools,
provider: params.provider,
config: params.config,
@@ -395,17 +395,14 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
modelId: params.modelId,
modelApi: params.model.api,
model: params.model,
// Registration is a projection of the already-prepared catalog. Never
// activate another provider runtime while constructing its durable schema.
allowProviderRuntimePluginLoad: options.ignoreRuntimePlan ? false : undefined,
onPreNormalizationSchemaDiagnostics: (diagnostics) =>
preNormalizationDiagnostics.push(...diagnostics),
});
toolBuildStages.mark("runtime-normalization");
// Resolve policy before hiding the managed tool. Hosted search follows the
// same effective policy, while only one search implementation is exposed.
input.onWebSearchPolicyResolved?.(normalizedTools.some((tool) => tool.name === "web_search"));
const exposedTools = webSearchPlan.suppressManagedWebSearch
? normalizedTools.filter((tool) => tool.name !== "web_search")
: normalizedTools;
if (preNormalizationDiagnostics.length > 0) {
markStage("runtime-normalization");
if (options.reportDiagnostics !== false && preNormalizationDiagnostics.length > 0) {
embeddedAgentLog.warn(
`codex app-server quarantined ${preNormalizationDiagnostics.length} unsupported runtime tool schema${preNormalizationDiagnostics.length === 1 ? "" : "s"} before dynamic tool registration`,
{
@@ -422,7 +419,7 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
}
const summary = toolBuildStages.snapshot();
if (shouldWarnCodexDynamicToolBuildStageSummary(summary)) {
const phase = input.forceHeartbeatTool ? "registered-tools" : "runtime-tools";
const phase = options.phase ?? "runtime-tools";
embeddedAgentLog.warn(
`codex app-server dynamic tool build timings runId=${params.runId} sessionId=${params.sessionId} phase=${phase} totalMs=${summary.totalMs} stages=${formatCodexDynamicToolBuildStageSummary(summary)}`,
{
@@ -435,9 +432,8 @@ export async function buildDynamicTools(input: DynamicToolBuildParams) {
codexFilteredToolCount: codexFilteredTools.length,
visionFilteredToolCount: visionFilteredTools.length,
filteredToolCount: filteredTools.length,
normalizedToolCount: exposedTools.length,
forceHeartbeatTool: input.forceHeartbeatTool === true,
ignoreRuntimePlan: input.ignoreRuntimePlan === true,
normalizedToolCount: normalizedTools.length,
ignoreRuntimePlan: options.ignoreRuntimePlan === true,
nativeToolSurfaceEnabled: input.nativeToolSurfaceEnabled === true,
},
);

View File

@@ -72,6 +72,12 @@ type CodexDynamicToolHookContext = {
type CodexToolResultHookContext = Omit<CodexDynamicToolHookContext, "config">;
type AgentToolResultObserver = (event: {
toolName: string;
result: unknown;
isError: boolean;
}) => void;
type ProjectedCodexDynamicTool = {
tool: AnyAgentTool;
name: string;
@@ -108,8 +114,7 @@ export type CodexDynamicToolBridge = {
params: CodexDynamicToolCallParams,
options?: {
signal?: AbortSignal;
onAgentToolResult?: EmbeddedRunAttemptParams["onAgentToolResult"];
toolCallOrdinal?: number;
onAgentToolResult?: AgentToolResultObserver;
},
) => Promise<CodexDynamicToolCallResponse>;
telemetry: {
@@ -442,7 +447,7 @@ export function createCodexDynamicToolBridge(params: {
}
function notifyAgentToolResult(
observer: EmbeddedRunAttemptParams["onAgentToolResult"] | undefined,
observer: AgentToolResultObserver | undefined,
toolName: string,
result: unknown,
isError: boolean,

View File

@@ -24,7 +24,6 @@ import {
type CodexAppServerEventProjectorOptions,
type CodexAppServerToolTelemetry,
} from "./event-projector.js";
import { rememberCodexRateLimits, resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
import { createCodexTestModel } from "./test-support.js";
const THREAD_ID = "thread-1";
@@ -108,7 +107,6 @@ afterEach(async () => {
resetAgentEventsForTest();
resetDiagnosticEventsForTest();
resetGlobalHookRunner();
resetCodexRateLimitCacheForTests();
vi.restoreAllMocks();
vi.unstubAllEnvs();
for (const tempDir of tempDirs) {
@@ -863,10 +861,11 @@ describe("CodexAppServerEventProjector", () => {
});
it("uses Codex rate-limit resets for usage-limit app-server errors", async () => {
const projector = await createProjector();
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
const projector = await createProjector(undefined, {
readRecentRateLimits: () => rateLimitsUpdated(resetsAt).params,
});
await projector.handleNotification(rateLimitsUpdated(resetsAt));
await projector.handleNotification(
forCurrentTurn("error", {
error: {
@@ -887,10 +886,11 @@ describe("CodexAppServerEventProjector", () => {
});
it("uses Codex rate-limit resets for failed turns", async () => {
const projector = await createProjector();
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
const projector = await createProjector(undefined, {
readRecentRateLimits: () => rateLimitsUpdated(resetsAt).params,
});
await projector.handleNotification(rateLimitsUpdated(resetsAt));
await projector.handleNotification(
forCurrentTurn("turn/completed", {
turn: {
@@ -914,9 +914,8 @@ describe("CodexAppServerEventProjector", () => {
});
it("uses a recent Codex rate-limit snapshot when failed turns omit reset details", async () => {
const projector = await createProjector();
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
rememberCodexRateLimits({
const rateLimits = {
rateLimits: {
limitId: "codex",
limitName: "Codex",
@@ -927,6 +926,9 @@ describe("CodexAppServerEventProjector", () => {
rateLimitReachedType: "rate_limit_reached",
},
rateLimitsByLimitId: null,
};
const projector = await createProjector(undefined, {
readRecentRateLimits: () => rateLimits,
});
await projector.handleNotification(
@@ -978,19 +980,19 @@ describe("CodexAppServerEventProjector", () => {
expect(result.promptErrorSource).toBe("prompt");
});
it("normalizes snake_case current token usage fields", async () => {
it("normalizes current app-server token usage", async () => {
const projector = await createProjector();
await projector.handleNotification(agentMessageDelta("done"));
await projector.handleNotification(
forCurrentTurn("thread/tokenUsage/updated", {
tokenUsage: {
total: { total_tokens: 1_000_000 },
last_token_usage: {
total_tokens: 17,
input_tokens: 8,
cached_input_tokens: 3,
output_tokens: 9,
total: { totalTokens: 1_000_000 },
last: {
totalTokens: 17,
inputTokens: 8,
cachedInputTokens: 3,
outputTokens: 9,
},
},
}),

View File

@@ -26,10 +26,7 @@ import type { AssistantMessage, Usage } from "openclaw/plugin-sdk/llm";
import { saveMediaBuffer } from "openclaw/plugin-sdk/media-store";
import { asDateTimestampMs } from "openclaw/plugin-sdk/number-runtime";
import { resolveCodexLocalRuntimeAttribution } from "./local-runtime-attribution.js";
import {
readCodexNotificationThreadId,
readCodexNotificationTurnId,
} from "./notification-correlation.js";
import { isCodexNotificationForTurn } from "./notification-correlation.js";
import { readCodexTurn } from "./protocol-validators.js";
import {
isJsonObject,
@@ -40,7 +37,6 @@ import {
type JsonObject,
type JsonValue,
} from "./protocol.js";
import { readRecentCodexRateLimits, rememberCodexRateLimits } from "./rate-limit-cache.js";
import { formatCodexUsageLimitErrorMessage } from "./rate-limits.js";
import { readCodexMirroredSessionHistoryMessages } from "./session-history.js";
import {
@@ -65,6 +61,7 @@ export type CodexAppServerToolTelemetry = {
export type CodexAppServerEventProjectorOptions = {
nativePostToolUseRelayEnabled?: boolean;
readRecentRateLimits?: () => JsonValue | undefined;
trajectoryRecorder?: CodexTrajectoryRecorder | null;
};
@@ -92,22 +89,6 @@ const ZERO_USAGE: Usage = {
},
};
const CURRENT_TOKEN_USAGE_KEYS = [
"last",
"current",
"lastCall",
"lastCallUsage",
"lastTokenUsage",
"last_token_usage",
] as const;
const CODEX_PROMPT_TOTAL_INPUT_KEYS = [
"inputTokens",
"input_tokens",
"promptTokens",
"prompt_tokens",
] as const;
const MAX_TOOL_OUTPUT_DELTA_MESSAGES_PER_ITEM = 20;
const TOOL_TRANSCRIPT_OUTPUT_MAX_CHARS = 12_000;
const MISSING_TOOL_RESULT_ERROR =
@@ -203,8 +184,6 @@ export class CodexAppServerEventProjector {
private tokenUsage: ReturnType<typeof normalizeUsage>;
private guardianReviewCount = 0;
private completedCompactionCount = 0;
private latestRateLimits: JsonValue | undefined;
constructor(
private readonly params: EmbeddedRunAttemptParams,
private readonly threadId: string,
@@ -241,11 +220,6 @@ export class CodexAppServerEventProjector {
if (!params) {
return;
}
if (notification.method === "account/rateLimits/updated") {
this.latestRateLimits = params;
rememberCodexRateLimits(params);
return;
}
if (isHookNotificationMethod(notification.method)) {
if (!this.isHookNotificationForCurrentThread(params)) {
return;
@@ -298,7 +272,7 @@ export class CodexAppServerEventProjector {
await this.handleRawResponseItemCompleted(params);
break;
case "error":
if (readBooleanAlias(params, ["willRetry", "will_retry"]) === true) {
if (params.willRetry === true) {
break;
}
this.promptError = this.formatCodexErrorMessage(params) ?? "codex app-server error";
@@ -709,9 +683,7 @@ export class CodexAppServerEventProjector {
private handleTokenUsage(params: JsonObject): void {
const tokenUsage = isJsonObject(params.tokenUsage) ? params.tokenUsage : undefined;
const current =
(tokenUsage ? readFirstJsonObject(tokenUsage, CURRENT_TOKEN_USAGE_KEYS) : undefined) ??
readFirstJsonObject(params, CURRENT_TOKEN_USAGE_KEYS);
const current = tokenUsage && isJsonObject(tokenUsage.last) ? tokenUsage.last : undefined;
if (!current) {
return;
}
@@ -782,7 +754,7 @@ export class CodexAppServerEventProjector {
formatCodexUsageLimitErrorMessage({
message: turn.error?.message,
codexErrorInfo: turn.error?.codexErrorInfo as JsonValue | null | undefined,
rateLimits: this.latestRateLimits ?? readRecentCodexRateLimits(),
rateLimits: this.options.readRecentRateLimits?.(),
}) ??
turn.error?.message ??
"codex app-server turn failed";
@@ -1689,7 +1661,7 @@ export class CodexAppServerEventProjector {
formatCodexUsageLimitErrorMessage({
message: error ? readString(error, "message") : undefined,
codexErrorInfo: error?.codexErrorInfo,
rateLimits: this.latestRateLimits ?? readRecentCodexRateLimits(),
rateLimits: this.options.readRecentRateLimits?.(),
}) ?? readCodexErrorNotificationMessage(params)
);
}
@@ -1884,9 +1856,7 @@ export class CodexAppServerEventProjector {
}
private isNotificationForTurn(params: JsonObject): boolean {
const threadId = readCodexNotificationThreadId(params);
const turnId = readNotificationTurnId(params);
return threadId === this.threadId && turnId === this.turnId;
return isCodexNotificationForTurn(params, this.threadId, this.turnId);
}
private isHookNotificationForCurrentThread(params: JsonObject): boolean {
@@ -1900,10 +1870,6 @@ function isHookNotificationMethod(method: string): method is "hook/started" | "h
return method === "hook/started" || method === "hook/completed";
}
function readNotificationTurnId(record: JsonObject): string | undefined {
return readCodexNotificationTurnId(record);
}
function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" ? value : undefined;
@@ -1993,21 +1959,6 @@ function readNonNegativeInteger(record: JsonObject, key: string): number | undef
return value !== undefined && Number.isInteger(value) && value >= 0 ? value : undefined;
}
function readBoolean(record: JsonObject, key: string): boolean | undefined {
const value = record[key];
return typeof value === "boolean" ? value : undefined;
}
function readBooleanAlias(record: JsonObject, keys: readonly string[]): boolean | undefined {
for (const key of keys) {
const value = readBoolean(record, key);
if (value !== undefined) {
return value;
}
}
return undefined;
}
function readCodexErrorNotificationMessage(record: JsonObject): string | undefined {
const error = record.error;
if (isJsonObject(error)) {
@@ -2035,52 +1986,19 @@ function readHookOutputEntries(
});
}
function readFirstJsonObject(record: JsonObject, keys: readonly string[]): JsonObject | undefined {
for (const key of keys) {
const value = record[key];
if (isJsonObject(value)) {
return value;
}
}
return undefined;
}
function readNumberAlias(record: JsonObject, keys: readonly string[]): number | undefined {
for (const key of keys) {
const value = readNumber(record, key);
if (value !== undefined) {
return value;
}
}
return undefined;
}
function normalizeCodexTokenUsage(record: JsonObject): ReturnType<typeof normalizeUsage> {
const promptTotalInput = readNumberAlias(record, CODEX_PROMPT_TOTAL_INPUT_KEYS);
const cacheRead = readNumberAlias(record, [
"cachedInputTokens",
"cached_input_tokens",
"cacheRead",
"cache_read",
"cache_read_input_tokens",
"cached_tokens",
]);
const promptTotalInput = readNumber(record, "inputTokens");
const cacheRead = readNumber(record, "cachedInputTokens");
const input =
promptTotalInput !== undefined && cacheRead !== undefined
? Math.max(0, promptTotalInput - cacheRead)
: (promptTotalInput ?? readNumber(record, "input"));
: promptTotalInput;
return normalizeUsage({
input,
output: readNumberAlias(record, ["outputTokens", "output_tokens", "output"]),
output: readNumber(record, "outputTokens"),
cacheRead,
cacheWrite: readNumberAlias(record, [
"cacheWrite",
"cache_write",
"cacheCreationInputTokens",
"cache_creation_input_tokens",
]),
total: readNumberAlias(record, ["totalTokens", "total_tokens", "total"]),
total: readNumber(record, "totalTokens"),
});
}

View File

@@ -8,6 +8,10 @@ import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerStartOptions } from "./config.js";
import { readCodexModelListResponse } from "./protocol-validators.js";
import type { CodexModel, CodexReasoningEffortOption } from "./protocol.js";
import {
createIsolatedCodexAppServerClient,
leaseSharedCodexAppServerClient,
} from "./shared-client.js";
/** Normalized model metadata returned by the Codex app-server model listing helper. */
export type CodexAppServerModel = {
@@ -36,10 +40,11 @@ export type CodexAppServerListModelsOptions = {
includeHidden?: boolean;
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string;
authProfileId?: string | null;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sharedClient?: boolean;
signal?: AbortSignal;
};
/** Lists one Codex app-server model page using the configured auth/client options. */
@@ -54,27 +59,37 @@ export async function listCodexAppServerModels(
/** Walks Codex app-server model pages until exhaustion or the max-page guard. */
export async function listAllCodexAppServerModels(
options: CodexAppServerListModelsOptions & { maxPages?: number } = {},
): Promise<CodexAppServerModelListResult> {
return await withCodexAppServerModelClient(options, async ({ client, timeoutMs }) =>
listAllCodexAppServerModelsWithClient(client, { ...options, timeoutMs }),
);
}
/** Walks all model pages on an already-owned physical app-server client. */
export async function listAllCodexAppServerModelsWithClient(
client: CodexAppServerClient,
options: CodexAppServerListModelsOptions & { maxPages?: number } = {},
): Promise<CodexAppServerModelListResult> {
const maxPages = normalizeMaxPages(options.maxPages);
return await withCodexAppServerModelClient(options, async ({ client, timeoutMs }) => {
const models: CodexAppServerModel[] = [];
let cursor = options.cursor;
let nextCursor: string | undefined;
for (let page = 0; page < maxPages; page += 1) {
const result = await requestModelListPage(client, {
...options,
timeoutMs,
cursor,
});
models.push(...result.models);
nextCursor = result.nextCursor;
if (!nextCursor) {
return { models };
}
cursor = nextCursor;
const timeoutMs = options.timeoutMs ?? 2500;
const models: CodexAppServerModel[] = [];
let cursor = options.cursor;
let nextCursor: string | undefined;
for (let page = 0; page < maxPages; page += 1) {
options.signal?.throwIfAborted();
const result = await requestModelListPage(client, {
...options,
timeoutMs,
cursor,
});
models.push(...result.models);
nextCursor = result.nextCursor;
if (!nextCursor) {
return { models };
}
return { models, nextCursor, truncated: true };
});
cursor = nextCursor;
}
return { models, nextCursor, truncated: true };
}
async function withCodexAppServerModelClient<T>(
@@ -83,33 +98,32 @@ async function withCodexAppServerModelClient<T>(
): Promise<T> {
const timeoutMs = options.timeoutMs ?? 2500;
const useSharedClient = options.sharedClient !== false;
const {
createIsolatedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
} = await import("./shared-client.js");
const client = useSharedClient
? await getLeasedSharedCodexAppServerClient({
const clientLease = useSharedClient
? await leaseSharedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
config: options.config,
abandonSignal: options.signal,
})
: await createIsolatedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
config: options.config,
});
: undefined;
const client =
clientLease?.client ??
(await createIsolatedCodexAppServerClient({
startOptions: options.startOptions,
timeoutMs,
authProfileId: options.authProfileId,
agentDir: options.agentDir,
config: options.config,
}));
try {
return await run({ client, timeoutMs });
} finally {
if (useSharedClient) {
releaseLeasedSharedCodexAppServerClient(client);
clientLease?.release();
} else {
client.close();
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
}
}
}
@@ -125,7 +139,7 @@ async function requestModelListPage(
cursor: options.cursor ?? null,
includeHidden: options.includeHidden ?? null,
},
{ timeoutMs: options.timeoutMs },
{ timeoutMs: options.timeoutMs, signal: options.signal },
);
return readModelListResult(response);
}

View File

@@ -4,7 +4,12 @@
*/
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { resolveSandboxRuntimeStatus } from "openclaw/plugin-sdk/sandbox";
import { getSessionEntry, type SessionEntry } from "openclaw/plugin-sdk/session-store-runtime";
import {
loadSessionStore,
resolveSessionStoreEntry,
resolveStorePath,
type SessionEntry,
} from "openclaw/plugin-sdk/session-store-runtime";
type ExecHost = "sandbox" | "gateway" | "node";
type ExecTarget = "auto" | ExecHost;
@@ -45,19 +50,17 @@ export function resolveCodexNativeExecutionPolicy(params: {
const config = params.config ?? {};
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim() || undefined;
const agentId = resolvePolicyAgentId({ config, sessionKey, agentId: params.agentId });
const canReadSessionEntry =
params.readRuntimeSessionEntry &&
shouldReadRuntimeSessionEntry({ config, sessionKey, agentId: params.agentId });
const sessionEntry =
params.sessionEntry ??
(canReadSessionEntry && sessionKey
? readRuntimeSessionEntryBestEffort({ sessionKey, agentId })
(params.readRuntimeSessionEntry && sessionKey
? readRuntimeSessionEntryBestEffort(config, sessionKey, agentId)
: undefined);
const sandboxAvailable =
params.sandboxAvailable ??
(sessionKey
? resolveSandboxRuntimeStatus({
cfg: config,
agentId,
sessionKey,
}).sandboxed
: false);
@@ -230,16 +233,17 @@ function resolveEffectiveExecHost(params: {
return params.requestedExecHost;
}
function readRuntimeSessionEntryBestEffort(params: {
sessionKey: string;
agentId: string;
}): SessionEntry | undefined {
function readRuntimeSessionEntryBestEffort(
config: OpenClawConfig,
sessionKey: string,
agentId: string,
): SessionEntry | undefined {
try {
return getSessionEntry({
sessionKey: params.sessionKey,
agentId: params.agentId,
hydrateSkillPromptRefs: false,
});
const storePath = resolveStorePath(config.session?.store, { agentId });
return resolveSessionStoreEntry({
store: loadSessionStore(storePath, { skipCache: true }),
sessionKey,
}).existing;
} catch {
return undefined;
}

View File

@@ -13,7 +13,6 @@ import {
addTimerTimeoutGraceMs,
finiteSecondsToTimerSafeMilliseconds,
} from "openclaw/plugin-sdk/number-runtime";
import type { CodexAppServerRuntimeOptions } from "./config.js";
import type { JsonObject, JsonValue } from "./protocol.js";
/** Codex hook events that can be registered through OpenClaw's native relay. */
@@ -24,8 +23,6 @@ export const CODEX_NATIVE_HOOK_RELAY_EVENTS: readonly NativeHookRelayEvent[] = [
"before_agent_finalize",
] as const;
const CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS =
CODEX_NATIVE_HOOK_RELAY_EVENTS.filter((event) => event !== "permission_request");
const CODEX_NATIVE_HOOK_RELAY_MIN_TTL_MS = 30 * 60_000;
/** Extra relay lifetime after the expected turn budget, preventing late hook drops. */
export const CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS = 5 * 60_000;
@@ -149,9 +146,8 @@ export function createCodexNativeHookRelay(params: {
allowedEvents: params.events,
ttlMs: resolveCodexNativeHookRelayTtlMs({
explicitTtlMs: params.options?.ttlMs,
attemptTimeoutMs: params.attemptTimeoutMs,
startupTimeoutMs: params.startupTimeoutMs,
turnStartTimeoutMs: params.turnStartTimeoutMs,
operationBudgetMs:
params.attemptTimeoutMs + params.startupTimeoutMs + params.turnStartTimeoutMs,
}),
signal: params.signal,
command: {
@@ -163,38 +159,27 @@ export function createCodexNativeHookRelay(params: {
});
}
/** Selects the native hook events Codex should install for the current approval mode. */
/** Selects the native hook events Codex should install for this thread. */
export function resolveCodexNativeHookRelayEvents(params: {
configuredEvents?: readonly NativeHookRelayEvent[];
appServer: Pick<CodexAppServerRuntimeOptions, "approvalPolicy">;
}): readonly NativeHookRelayEvent[] {
if (params.configuredEvents?.length) {
return params.configuredEvents;
}
// Codex emits PermissionRequest before the app-server approval reviewer has
// resolved the command. In native approval modes, let Codex's app-server
// approval bridge own the real escalation instead of surfacing a stale
// pre-guardian OpenClaw plugin approval prompt.
return params.appServer.approvalPolicy === "never"
? CODEX_NATIVE_HOOK_RELAY_EVENTS
: CODEX_NATIVE_HOOK_RELAY_EVENTS_WITH_APP_SERVER_APPROVALS;
// Thread config is fixed before Codex reports the authoritative provider.
// Install the stable superset; the relay defers permission prompts from guarded turns.
return CODEX_NATIVE_HOOK_RELAY_EVENTS;
}
/** Derives the native hook relay TTL from the turn budget unless explicitly configured. */
export function resolveCodexNativeHookRelayTtlMs(params: {
explicitTtlMs: number | undefined;
attemptTimeoutMs: number;
startupTimeoutMs: number;
turnStartTimeoutMs: number;
operationBudgetMs: number;
}): number {
if (params.explicitTtlMs !== undefined) {
return params.explicitTtlMs;
}
const relayBudgetMs =
params.attemptTimeoutMs +
params.startupTimeoutMs +
params.turnStartTimeoutMs +
CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS;
const relayBudgetMs = params.operationBudgetMs + CODEX_NATIVE_HOOK_RELAY_TTL_GRACE_MS;
return Math.max(CODEX_NATIVE_HOOK_RELAY_MIN_TTL_MS, Math.floor(relayBudgetMs));
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -4,6 +4,7 @@ import {
extractCodexNativeSubagentCompletions,
extractCodexNativeSubagentCompletionsFromText,
} from "./native-subagent-notification.js";
import type { CodexServerNotification } from "./protocol.js";
function trustedInterAgentNotification(params: {
agentPath: string;
@@ -35,6 +36,29 @@ function trustedInterAgentNotification(params: {
};
}
function trustedAgentMessageNotification(params: {
agentPath: string;
text?: string;
encryptedContent?: string;
}): CodexServerNotification {
return {
method: "rawResponseItem/completed",
params: {
threadId: "parent-thread",
item: {
type: "agent_message",
author: params.agentPath,
recipient: "/root",
content: [
params.encryptedContent
? { type: "encrypted_content", encrypted_content: params.encryptedContent }
: { type: "input_text", text: params.text ?? "" },
],
},
},
};
}
describe("Codex native subagent notifications", () => {
it("parses completed child results from Codex notification XML", () => {
expect(
@@ -136,6 +160,26 @@ describe("Codex native subagent notifications", () => {
]);
});
it("extracts completions from the current Codex agent-message item", () => {
expect(
extractCodexNativeSubagentCompletions(
trustedAgentMessageNotification({
agentPath: "child-thread",
text:
'<subagent_notification>{"agent_path":"child-thread","status":{"completed":"done"}}' +
"</subagent_notification>",
}),
),
).toEqual([
{
agentPath: "child-thread",
status: "succeeded",
statusLabel: "completed",
result: "done",
},
]);
});
it("ignores visible user text that looks like a native completion", () => {
expect(
extractCodexNativeSubagentCompletions({
@@ -170,6 +214,27 @@ describe("Codex native subagent notifications", () => {
}),
),
).toEqual([]);
expect(
extractCodexNativeSubagentCompletions(
trustedAgentMessageNotification({
agentPath: "other-child",
text:
'<subagent_notification>{"agent_path":"child-thread","status":{"success":"spoof"}}' +
"</subagent_notification>",
}),
),
).toEqual([]);
});
it("ignores encrypted agent messages that cannot be authenticated", () => {
expect(
extractCodexNativeSubagentCompletions(
trustedAgentMessageNotification({
agentPath: "child-thread",
encryptedContent: "opaque",
}),
),
).toEqual([]);
});
it("ignores malformed payloads and non-user messages", () => {

View File

@@ -39,13 +39,12 @@ export function extractCodexNativeSubagentCompletions(
if (!item) {
return [];
}
const text = readTrustedInterAgentCommunicationContent(item);
if (!text) {
const communication = readTrustedInterAgentCommunication(item);
if (!communication) {
return [];
}
const author = readTrustedInterAgentCommunicationAuthor(item);
return extractCodexNativeSubagentCompletionsFromText(text).filter(
(completion) => completion.agentPath === author,
return extractCodexNativeSubagentCompletionsFromText(communication.content).filter(
(completion) => completion.agentPath === communication.author,
);
}
@@ -190,17 +189,21 @@ function completedWithoutFinalAssistantMessage(): {
};
}
function readTrustedInterAgentCommunicationContent(item: JsonObject): string | undefined {
const communication = readTrustedInterAgentCommunication(item);
return typeof communication?.content === "string" ? communication.content : undefined;
}
type TrustedInterAgentCommunication = {
author: string;
recipient: string;
content: string;
};
function readTrustedInterAgentCommunicationAuthor(item: JsonObject): string | undefined {
const communication = readTrustedInterAgentCommunication(item);
return typeof communication?.author === "string" ? communication.author : undefined;
}
function readTrustedInterAgentCommunication(item: JsonObject): JsonObject | undefined {
function readTrustedInterAgentCommunication(
item: JsonObject,
): TrustedInterAgentCommunication | undefined {
if (readString(item, "type") === "agent_message") {
const author = readString(item, "author")?.trim();
const recipient = readString(item, "recipient")?.trim();
const content = extractSingleTextPart(item, "input_text");
return author && recipient && content ? { author, recipient, content } : undefined;
}
if (
readString(item, "type") !== "message" ||
readString(item, "role") !== "assistant" ||
@@ -208,7 +211,7 @@ function readTrustedInterAgentCommunication(item: JsonObject): JsonObject | unde
) {
return undefined;
}
const text = extractSingleTextPart(item);
const text = extractSingleTextPart(item, "output_text", "text");
if (!text) {
return undefined;
}
@@ -221,18 +224,20 @@ function readTrustedInterAgentCommunication(item: JsonObject): JsonObject | unde
if (!isJsonObject(parsed)) {
return undefined;
}
const author = typeof parsed.author === "string" ? parsed.author.trim() : "";
const recipient = typeof parsed.recipient === "string" ? parsed.recipient.trim() : "";
if (
typeof parsed.author !== "string" ||
typeof parsed.recipient !== "string" ||
!author ||
!recipient ||
typeof parsed.content !== "string" ||
parsed.trigger_turn !== false
) {
return undefined;
}
return parsed;
return { author, recipient, content: parsed.content };
}
function extractSingleTextPart(item: JsonObject): string | undefined {
function extractSingleTextPart(item: JsonObject, ...acceptedTypes: string[]): string | undefined {
const content = item.content;
if (!Array.isArray(content) || content.length !== 1) {
return undefined;
@@ -242,7 +247,7 @@ function extractSingleTextPart(item: JsonObject): string | undefined {
return undefined;
}
const type = readString(entry, "type");
if (type !== "output_text" && type !== "text") {
if (!type || !acceptedTypes.includes(type)) {
return undefined;
}
return readString(entry, "text")?.trim();

View File

@@ -56,8 +56,8 @@ export class CodexNativeSubagentTaskMirror {
}
markAuthoritativeCompletionExpected(childThreadId: string): void {
// Local transcripts and V2 agent paths can supply the real result later.
// Remote V1 lacks both and must keep collab-completed as its fallback.
// The monitor recovers the authoritative result through app-server history.
// Keep collab completion as progress so it cannot finalize stale text first.
this.expectedAuthoritativeRunIds.add(codexNativeSubagentRunId(childThreadId));
}

View File

@@ -2,28 +2,7 @@
* Correlates Codex app-server notifications with the active thread/turn so
* projectors can ignore global or stale events without losing diagnostics.
*/
import {
isJsonObject,
type CodexServerNotification,
type JsonObject,
type JsonValue,
} from "./protocol.js";
/** Debug-friendly correlation summary for a Codex app-server notification. */
export type CodexNotificationCorrelation = {
method: string;
paramsKeys?: string[];
activeThreadId: string;
activeTurnId?: string;
threadId?: string;
turnId?: string;
nestedTurnThreadId?: string;
nestedTurnId?: string;
turnStatus?: string;
turnItemCount?: number;
matchesActiveThread: boolean;
matchesActiveTurn?: boolean;
};
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
/** Returns true when a notification payload belongs to the exact active thread and turn. */
export function isCodexNotificationForTurn(
@@ -40,9 +19,10 @@ export function isCodexNotificationForTurn(
);
}
/** Reads a thread id from either top-level notification params or nested turn payloads. */
/** Reads a thread id from canonical top-level or nested thread payloads. */
export function readCodexNotificationThreadId(record: JsonObject): string | undefined {
return readNestedTurnThreadId(record) ?? readString(record, "threadId");
const thread = isJsonObject(record.thread) ? record.thread : undefined;
return readString(record, "threadId") ?? (thread ? readString(thread, "id") : undefined);
}
/** Reads a turn id from either top-level notification params or nested turn payloads. */
@@ -50,50 +30,11 @@ export function readCodexNotificationTurnId(record: JsonObject): string | undefi
return readNestedTurnId(record) ?? readString(record, "turnId");
}
/** Builds structured correlation details for logs when notification routing is ambiguous. */
export function describeCodexNotificationCorrelation(
notification: CodexServerNotification,
active: { threadId: string; turnId?: string },
): CodexNotificationCorrelation {
const params = isJsonObject(notification.params) ? notification.params : undefined;
const turn = params && isJsonObject(params.turn) ? params.turn : undefined;
const threadId = params ? readString(params, "threadId") : undefined;
const turnId = params ? readString(params, "turnId") : undefined;
const nestedTurnThreadId = turn ? readString(turn, "threadId") : undefined;
const nestedTurnId = turn ? readString(turn, "id") : undefined;
const resolvedThreadId = params ? readCodexNotificationThreadId(params) : undefined;
const resolvedTurnId = params ? readCodexNotificationTurnId(params) : undefined;
const matchesActiveThread = resolvedThreadId === active.threadId;
const matchesActiveTurn = active.turnId
? matchesActiveThread && resolvedTurnId === active.turnId
: undefined;
const items = turn?.items;
return {
method: notification.method,
...(params ? { paramsKeys: Object.keys(params).toSorted() } : {}),
activeThreadId: active.threadId,
...(active.turnId ? { activeTurnId: active.turnId } : {}),
...(threadId ? { threadId } : {}),
...(turnId ? { turnId } : {}),
...(nestedTurnThreadId ? { nestedTurnThreadId } : {}),
...(nestedTurnId ? { nestedTurnId } : {}),
...(turn ? { turnStatus: readString(turn, "status") } : {}),
...(Array.isArray(items) ? { turnItemCount: items.length } : {}),
matchesActiveThread,
...(matchesActiveTurn === undefined ? {} : { matchesActiveTurn }),
};
}
function readNestedTurnId(record: JsonObject): string | undefined {
const turn = record.turn;
return isJsonObject(turn) ? readString(turn, "id") : undefined;
}
function readNestedTurnThreadId(record: JsonObject): string | undefined {
const turn = record.turn;
return isJsonObject(turn) ? readString(turn, "threadId") : undefined;
}
function readString(record: JsonObject, key: string): string | undefined {
const value = record[key];
return typeof value === "string" && value.trim() ? value.trim() : undefined;

View File

@@ -0,0 +1,4 @@
/** Joins non-empty Codex prompt sections with stable paragraph spacing. */
export function joinCodexPromptSections(...sections: Array<string | undefined>): string {
return sections.filter((section): section is string => Boolean(section?.trim())).join("\n\n");
}

View File

@@ -60,14 +60,6 @@ describe("assertCodexThreadStartResponse", () => {
expect(result.thread.sessionId).toBe("session-1");
});
it("normalizes missing id from sessionId", () => {
const response = makeMinimalResponse({ id: undefined, sessionId: "session-1" });
delete (response.thread as Record<string, unknown>).id;
const result = assertCodexThreadStartResponse(response);
expect(result.thread.id).toBe("session-1");
expect(result.thread.sessionId).toBe("session-1");
});
it("throws on invalid response", () => {
expect(() => assertCodexThreadStartResponse({})).toThrow("Invalid Codex app-server");
});

View File

@@ -8,7 +8,6 @@ import errorNotificationSchema from "./protocol-generated/json/v2/ErrorNotificat
import modelListResponseSchema from "./protocol-generated/json/v2/ModelListResponse.json" with { type: "json" };
import threadResumeResponseSchema from "./protocol-generated/json/v2/ThreadResumeResponse.json" with { type: "json" };
import threadStartResponseSchema from "./protocol-generated/json/v2/ThreadStartResponse.json" with { type: "json" };
import turnCompletedNotificationSchema from "./protocol-generated/json/v2/TurnCompletedNotification.json" with { type: "json" };
import turnStartResponseSchema from "./protocol-generated/json/v2/TurnStartResponse.json" with { type: "json" };
import type {
CodexDynamicToolCallParams,
@@ -18,7 +17,6 @@ import type {
CodexThreadResumeResponse,
CodexThreadStartResponse,
CodexTurn,
CodexTurnCompletedNotification,
CodexTurnStartResponse,
} from "./protocol.js";
@@ -221,9 +219,6 @@ const validateThreadResumeResponse = compileCodexSchema<CodexThreadResumeRespons
);
const validateThreadStartResponse =
compileCodexSchema<CodexThreadStartResponse>(threadStartResponseSchema);
const validateTurnCompletedNotification = compileCodexSchema<CodexTurnCompletedNotification>(
turnCompletedNotificationSchema,
);
const validateTurnStartResponse =
compileCodexSchema<CodexTurnStartResponse>(turnStartResponseSchema);
@@ -298,19 +293,6 @@ export function readCodexTurn(value: unknown): CodexTurn | undefined {
return response?.turn;
}
/** Reads a Codex turn/completed notification payload if it matches the protocol schema. */
export function readCodexTurnCompletedNotification(
value: unknown,
): CodexTurnCompletedNotification | undefined {
return readCodexShape(
validateTurnCompletedNotification,
normalizeWithDefaults(
turnCompletedNotificationSchema,
normalizeTurnCompletedNotification(value),
),
);
}
function assertCodexShape<T>(validate: CodexValidator<T>, value: unknown, label: string): T {
if (validate.check(value)) {
return value;
@@ -375,9 +357,6 @@ function normalizeThreadResponse(value: unknown): unknown {
if (typeof t.id === "string" && typeof t.sessionId !== "string") {
return { ...value, thread: { ...thread, sessionId: t.id } };
}
if (typeof t.sessionId === "string" && typeof t.id !== "string") {
return { ...value, thread: { ...thread, id: t.sessionId } };
}
}
return value;
}
@@ -392,16 +371,6 @@ function normalizeTurnStartResponse(value: unknown): unknown {
};
}
function normalizeTurnCompletedNotification(value: unknown): unknown {
if (!value || typeof value !== "object" || Array.isArray(value) || !("turn" in value)) {
return value;
}
return {
...value,
turn: normalizeTurn((value as { turn?: unknown }).turn),
};
}
function formatValidationErrors(validate: CodexValidator<unknown>, value: unknown): string {
const errors = validate.errors(value);
if (!errors || errors.length === 0) {

View File

@@ -139,6 +139,7 @@ export type CodexThreadResumeParams = JsonObject & {
serviceTier?: CodexServiceTier | null;
config?: JsonObject;
developerInstructions?: string;
excludeTurns?: boolean;
/** Retired by Codex 0.137, but still sent for supported custom app-server 0.125-0.136. */
persistExtendedHistory?: boolean;
};
@@ -146,7 +147,10 @@ export type CodexThreadResumeParams = JsonObject & {
export type CodexThreadStartResponse = {
thread: CodexThread;
model: string;
modelProvider?: string | null;
modelProvider: string;
approvalPolicy: string | JsonObject;
approvalsReviewer: string;
sandbox: CodexSandboxPolicy;
};
export type CodexThreadForkParams = CodexThreadStartParams & {
@@ -162,7 +166,22 @@ export type CodexThreadForkResponse = CodexThreadStartResponse;
export type CodexThreadResumeResponse = {
thread: CodexThread;
model: string;
modelProvider?: string | null;
modelProvider: string;
approvalPolicy: string | JsonObject;
approvalsReviewer: string;
sandbox: CodexSandboxPolicy;
};
export type CodexThreadReadParams = JsonObject & {
threadId: string;
includeTurns?: boolean;
};
export type CodexThreadReadResponse = {
thread: CodexThread & {
parentThreadId?: string | null;
turns?: JsonObject[];
};
};
export type CodexThreadInjectItemsParams = JsonObject & {
@@ -207,11 +226,10 @@ export type CodexTurnStartResponse = {
export type CodexTurn = {
id: string;
threadId: string;
status?: string;
error?: CodexErrorNotification["error"];
startedAt?: string | null;
completedAt?: string | null;
startedAt?: number | null;
completedAt?: number | null;
durationMs?: number | null;
items: CodexThreadItem[];
};
@@ -229,6 +247,7 @@ export type CodexThread = {
threadSource?: string | null;
agentNickname?: string | null;
agentRole?: string | null;
turns: CodexTurn[];
};
export type CodexThreadStatus =
@@ -564,6 +583,7 @@ type CodexAppServerRequestParamsOverride = {
"environment/add": { environmentId: string; execServerUrl: string };
"thread/fork": CodexThreadForkParams;
"thread/inject_items": CodexThreadInjectItemsParams;
"thread/read": CodexThreadReadParams;
"thread/start": CodexThreadStartParams;
"thread/unsubscribe": CodexThreadUnsubscribeParams;
"turn/interrupt": CodexTurnInterruptParams;
@@ -592,6 +612,7 @@ type CodexAppServerRequestResultMap = {
"thread/fork": CodexThreadForkResponse;
"thread/inject_items": JsonValue;
"thread/list": JsonValue;
"thread/read": CodexThreadReadResponse;
"thread/resume": CodexThreadResumeResponse;
"thread/start": CodexThreadStartResponse;
"thread/unsubscribe": JsonValue;
@@ -604,6 +625,14 @@ export function isJsonObject(value: unknown): value is JsonObject {
return Boolean(value && typeof value === "object" && !Array.isArray(value));
}
/** Reads the thread identity whose subscription the client retained on create. */
export function readCodexThreadCreationResponseId(value: unknown): string | undefined {
if (!isJsonObject(value) || !isJsonObject(value.thread) || typeof value.thread.id !== "string") {
return undefined;
}
return value.thread.id.trim() || undefined;
}
export function isRpcResponse(message: RpcMessage): message is RpcResponse {
return "id" in message && !("method" in message);
}

View File

@@ -1,8 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerRuntimeOptions } from "./config.js";
import { resolveCodexProviderWebSearchSupport } from "./provider-capabilities.js";
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
const appServer = {
start: {},
@@ -13,12 +13,16 @@ function createClientFactory(webSearch: boolean | boolean[]) {
const values = Array.isArray(webSearch) ? [...webSearch] : [webSearch];
const request = vi.fn(async () => ({ webSearch: values.shift() ?? false }));
const client = { request } as unknown as CodexAppServerClient;
const clientFactory = vi.fn(async () => client) as unknown as CodexAppServerClientFactory;
return { clientFactory, request };
const release = vi.fn();
const clientFactory = vi.fn(async () => ({
client,
release,
})) as CodexAppServerClientLeaseFactory;
return { clientFactory, release, request };
}
function resolveSupport(
clientFactory: CodexAppServerClientFactory,
clientFactory: CodexAppServerClientLeaseFactory,
modelProviderOverride?: string,
) {
return resolveCodexProviderWebSearchSupport({
@@ -50,7 +54,7 @@ describe("resolveCodexProviderWebSearchSupport", () => {
it("reports unknown support when app-server startup fails", async () => {
const clientFactory = vi.fn(async () => {
throw new Error("old app-server");
}) as unknown as CodexAppServerClientFactory;
}) as CodexAppServerClientLeaseFactory;
await expect(resolveSupport(clientFactory)).resolves.toBe("unknown");
});
@@ -60,10 +64,15 @@ describe("resolveCodexProviderWebSearchSupport", () => {
throw new Error("transient rpc failure");
});
const client = { request } as unknown as CodexAppServerClient;
const clientFactory = vi.fn(async () => client) as unknown as CodexAppServerClientFactory;
const release = vi.fn();
const clientFactory = vi.fn(async () => ({
client,
release,
})) as CodexAppServerClientLeaseFactory;
await expect(resolveSupport(clientFactory)).resolves.toBe("unknown");
expect(request).toHaveBeenCalledOnce();
expect(release).toHaveBeenCalledOnce();
});
it("keeps managed search when the configured provider reports no hosted support", async () => {

View File

@@ -1,8 +1,10 @@
import type { EmbeddedRunAttemptParams } from "openclaw/plugin-sdk/agent-harness-runtime";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexAppServerRuntimeOptions } from "./config.js";
import { releaseLeasedSharedCodexAppServerClient } from "./shared-client.js";
import type {
CodexAppServerClientLease,
CodexAppServerClientLeaseFactory,
} from "./shared-client.js";
import type { CodexNativeWebSearchSupport } from "./web-search.js";
async function readConfiguredProviderWebSearchSupport(params: {
@@ -45,7 +47,7 @@ export async function resolveCodexProviderWebSearchSupportForClient(params: {
}
export async function resolveCodexProviderWebSearchSupport(params: {
clientFactory: CodexAppServerClientFactory;
clientFactory: CodexAppServerClientLeaseFactory;
appServer: CodexAppServerRuntimeOptions;
authProfileId: string | undefined;
agentDir: string;
@@ -53,17 +55,17 @@ export async function resolveCodexProviderWebSearchSupport(params: {
modelProviderOverride: string | undefined;
signal: AbortSignal;
}): Promise<CodexNativeWebSearchSupport> {
let client: CodexAppServerClient | undefined;
let lease: CodexAppServerClientLease | undefined;
try {
client = await params.clientFactory(
params.appServer.start,
params.authProfileId,
params.agentDir,
params.config,
{ timeoutMs: params.appServer.requestTimeoutMs },
);
lease = await params.clientFactory({
startOptions: params.appServer.start,
authProfileId: params.authProfileId,
agentDir: params.agentDir,
config: params.config,
timeoutMs: params.appServer.requestTimeoutMs,
});
return await resolveCodexProviderWebSearchSupportForClient({
client,
client: lease.client,
timeoutMs: params.appServer.requestTimeoutMs,
modelProviderOverride: params.modelProviderOverride,
signal: params.signal,
@@ -71,8 +73,6 @@ export async function resolveCodexProviderWebSearchSupport(params: {
} catch {
return "unknown";
} finally {
if (client) {
releaseLeasedSharedCodexAppServerClient(client);
}
lease?.release();
}
}

View File

@@ -0,0 +1,124 @@
// Codex tests cover physical-client rate-limit snapshot ownership and rolling merges.
import { describe, expect, it } from "vitest";
import type { CodexAppServerClient } from "./client.js";
import {
mergeCodexRateLimitsUpdate,
readCodexRateLimitsRevision,
readRecentCodexRateLimits,
rememberCodexRateLimitsRead,
} from "./rate-limit-cache.js";
function clientIdentity(): CodexAppServerClient {
return {} as unknown as CodexAppServerClient;
}
describe("Codex rate-limit cache", () => {
it("isolates snapshots by physical client", () => {
const first = clientIdentity();
const second = clientIdentity();
expect(readCodexRateLimitsRevision(first)).toBe(0);
rememberCodexRateLimitsRead(first, { rateLimits: { limitId: "first" } }, 100);
rememberCodexRateLimitsRead(second, { rateLimits: { limitId: "second" } }, 200);
expect(readCodexRateLimitsRevision(first, "first")).toBe(1);
expect(readCodexRateLimitsRevision(second, "second")).toBe(1);
expect(readRecentCodexRateLimits(first, { nowMs: 250 })).toEqual({
rateLimits: { limitId: "first" },
});
expect(readRecentCodexRateLimits(second, { nowMs: 250 })).toEqual({
rateLimits: { limitId: "second" },
});
expect(readRecentCodexRateLimits(first, { nowMs: 301, maxAgeMs: 200 })).toBeUndefined();
expect(readRecentCodexRateLimits(second, { nowMs: 301, maxAgeMs: 200 })).toEqual({
rateLimits: { limitId: "second" },
});
});
it("merges sparse rolling updates without clearing account metadata", () => {
const client = clientIdentity();
const codexSnapshot = {
limitId: "codex",
limitName: "Codex",
primary: { usedPercent: 10, windowDurationMins: 300, resetsAt: 1000 },
secondary: { usedPercent: 20, windowDurationMins: 10_080, resetsAt: 2000 },
credits: { hasCredits: true, unlimited: false, balance: "5" },
individualLimit: {
limit: "25000",
used: "8000",
remainingPercent: 68,
resetsAt: 3000,
},
planType: "pro",
rateLimitReachedType: "rate_limit_reached",
};
const otherSnapshot = {
limitId: "codex_other",
limitName: "Other",
primary: { usedPercent: 30, windowDurationMins: 60, resetsAt: 4000 },
secondary: null,
credits: null,
individualLimit: null,
planType: "pro",
rateLimitReachedType: null,
};
rememberCodexRateLimitsRead(client, {
rateLimits: codexSnapshot,
rateLimitsByLimitId: { codex: codexSnapshot, codex_other: otherSnapshot },
});
mergeCodexRateLimitsUpdate(client, {
rateLimits: {
limitId: null,
limitName: null,
primary: { usedPercent: 90, windowDurationMins: 300, resetsAt: 5000 },
secondary: null,
credits: null,
individualLimit: null,
planType: null,
rateLimitReachedType: null,
},
});
mergeCodexRateLimitsUpdate(client, {
rateLimits: {
limitId: "codex_other",
limitName: null,
primary: { usedPercent: 75, windowDurationMins: 60, resetsAt: 6000 },
secondary: null,
credits: null,
individualLimit: null,
planType: null,
rateLimitReachedType: null,
},
});
expect(readCodexRateLimitsRevision(client)).toBe(2);
expect(readCodexRateLimitsRevision(client, "codex_other")).toBe(2);
const mergedCodexSnapshot = {
limitId: "codex",
limitName: null,
primary: { usedPercent: 90, windowDurationMins: 300, resetsAt: 5000 },
secondary: null,
credits: codexSnapshot.credits,
individualLimit: codexSnapshot.individualLimit,
planType: "pro",
rateLimitReachedType: null,
};
const mergedOtherSnapshot = {
limitId: "codex_other",
limitName: null,
primary: { usedPercent: 75, windowDurationMins: 60, resetsAt: 6000 },
secondary: null,
credits: codexSnapshot.credits,
individualLimit: codexSnapshot.individualLimit,
planType: "pro",
rateLimitReachedType: null,
};
expect(readRecentCodexRateLimits(client)).toEqual({
rateLimits: mergedCodexSnapshot,
rateLimitsByLimitId: {
codex: mergedCodexSnapshot,
codex_other: mergedOtherSnapshot,
},
});
});
});

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