Compare commits

...

291 Commits

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,2 +1,2 @@
118c0f05ded3d3671e4caca646f8c5c13799757705fec2d769b1657367ec0243 plugin-sdk-api-baseline.json
6795c59b8ce6c8203bfca5d932b562d3d2b718e93701faa3a52e57cb45d277d4 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

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

View File

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

View File

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

View File

@@ -1,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 });
}
});
});

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

@@ -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,
},
});
});
});

View File

@@ -1,55 +1,166 @@
/**
* Keeps the latest Codex app-server rate-limit payload in process-global state
* so failure handling can enrich later usage-limit errors.
*/
import type { JsonValue } from "./protocol.js";
/** Client-owned Codex app-server rate-limit snapshots. */
import type { CodexAppServerClient } from "./client.js";
import { isJsonObject, type JsonObject, type JsonValue } from "./protocol.js";
const DEFAULT_CODEX_RATE_LIMIT_CACHE_MAX_AGE_MS = 10 * 60_000;
const CODEX_RATE_LIMIT_CACHE_STATE = Symbol.for("openclaw.codexRateLimitCacheState");
const SPARSE_ACCOUNT_METADATA_KEYS = ["credits", "individualLimit", "planType"] as const;
type CodexRateLimitCacheState = {
value?: JsonValue;
updatedAtMs?: number;
value: JsonValue;
updatedAtMs: number;
revisionsByLimitId: Record<string, number>;
};
function getCodexRateLimitCacheState(): CodexRateLimitCacheState {
const globalState = globalThis as typeof globalThis & {
[CODEX_RATE_LIMIT_CACHE_STATE]?: CodexRateLimitCacheState;
};
globalState[CODEX_RATE_LIMIT_CACHE_STATE] ??= {};
return globalState[CODEX_RATE_LIMIT_CACHE_STATE];
const rateLimitsByClient = new WeakMap<CodexAppServerClient, CodexRateLimitCacheState>();
/** Replaces one physical client's cache with an authoritative rate-limit read response. */
export function rememberCodexRateLimitsRead(
client: CodexAppServerClient,
value: JsonValue | undefined,
nowMs = Date.now(),
): void {
if (value !== undefined) {
const currentState = rateLimitsByClient.get(client);
const revisionsByLimitId = { ...currentState?.revisionsByLimitId };
for (const limitId of readRateLimitIds(value)) {
revisionsByLimitId[limitId] = (revisionsByLimitId[limitId] ?? 0) + 1;
}
rateLimitsByClient.set(client, {
value,
updatedAtMs: nowMs,
revisionsByLimitId,
});
}
}
/** Stores a non-empty Codex rate-limit payload with its observation time. */
export function rememberCodexRateLimits(value: JsonValue | undefined, nowMs = Date.now()): void {
if (value === undefined) {
/** Merges a sparse rolling notification into one physical client's latest read response. */
export function mergeCodexRateLimitsUpdate(
client: CodexAppServerClient,
value: JsonValue | undefined,
nowMs = Date.now(),
): void {
const update =
isJsonObject(value) && isJsonObject(value.rateLimits) ? value.rateLimits : undefined;
if (!update) {
return;
}
const state = getCodexRateLimitCacheState();
state.value = value;
state.updatedAtMs = nowMs;
const currentState = rateLimitsByClient.get(client);
const current = currentState?.value;
const limitId = readLimitId(update);
rateLimitsByClient.set(client, {
value: mergeRateLimitUpdate(current, update),
updatedAtMs: nowMs,
revisionsByLimitId: {
...currentState?.revisionsByLimitId,
[limitId]: (currentState?.revisionsByLimitId[limitId] ?? 0) + 1,
},
});
}
/** Reads the cached Codex rate-limit payload when it is still within the max-age window. */
export function readRecentCodexRateLimits(options?: {
nowMs?: number;
maxAgeMs?: number;
}): JsonValue | undefined {
const state = getCodexRateLimitCacheState();
if (state.value === undefined || state.updatedAtMs === undefined) {
/** Per-limit marker used to trust only primary Codex updates from one turn startup. */
export function readCodexRateLimitsRevision(
client: CodexAppServerClient,
limitId = "codex",
): number {
return rateLimitsByClient.get(client)?.revisionsByLimitId[limitId] ?? 0;
}
/** Reads one physical client's cached rate-limit payload within the max-age window. */
export function readRecentCodexRateLimits(
client: CodexAppServerClient,
options?: {
nowMs?: number;
maxAgeMs?: number;
},
): JsonValue | undefined {
const state = rateLimitsByClient.get(client);
if (!state) {
return undefined;
}
const nowMs = options?.nowMs ?? Date.now();
const maxAgeMs = options?.maxAgeMs ?? DEFAULT_CODEX_RATE_LIMIT_CACHE_MAX_AGE_MS;
if (maxAgeMs >= 0 && nowMs - state.updatedAtMs > maxAgeMs) {
return undefined;
}
return state.value;
return maxAgeMs >= 0 && nowMs - state.updatedAtMs > maxAgeMs ? undefined : state.value;
}
/** Clears the process-global rate-limit cache for deterministic tests. */
export function resetCodexRateLimitCacheForTests(): void {
const state = getCodexRateLimitCacheState();
state.value = undefined;
state.updatedAtMs = undefined;
function mergeRateLimitUpdate(current: JsonValue | undefined, update: JsonObject): JsonObject {
const currentEnvelope = isJsonObject(current) ? current : undefined;
const currentPrimary =
currentEnvelope && isJsonObject(currentEnvelope.rateLimits)
? currentEnvelope.rateLimits
: undefined;
const currentByLimitId =
currentEnvelope && isJsonObject(currentEnvelope.rateLimitsByLimitId)
? currentEnvelope.rateLimitsByLimitId
: undefined;
const limitId = readLimitId(update);
const currentPrimaryLimitId = currentPrimary ? readLimitId(currentPrimary) : undefined;
const currentForLimit =
(currentByLimitId && isJsonObject(currentByLimitId[limitId])
? currentByLimitId[limitId]
: undefined) ?? (currentPrimaryLimitId === limitId ? currentPrimary : undefined);
const merged = mergeSparseSnapshot(
isJsonObject(currentForLimit) ? currentForLimit : undefined,
currentPrimary,
update,
limitId,
);
const nextPrimary =
!currentPrimary || currentPrimaryLimitId === limitId ? merged : currentPrimary;
let nextByLimitId: JsonObject | undefined;
if (currentByLimitId) {
nextByLimitId = { ...currentByLimitId, [limitId]: merged };
} else if (currentPrimary && currentPrimaryLimitId && currentPrimaryLimitId !== limitId) {
nextByLimitId = {
[currentPrimaryLimitId]: currentPrimary,
[limitId]: merged,
};
}
return {
...currentEnvelope,
rateLimits: nextPrimary,
...(nextByLimitId ? { rateLimitsByLimitId: nextByLimitId } : {}),
};
}
function readRateLimitIds(value: JsonValue): string[] {
if (!isJsonObject(value)) {
return [];
}
const ids = new Set<string>();
if (isJsonObject(value.rateLimits)) {
ids.add(readLimitId(value.rateLimits));
}
if (isJsonObject(value.rateLimitsByLimitId)) {
for (const [key, snapshot] of Object.entries(value.rateLimitsByLimitId)) {
const snapshotLimitId =
isJsonObject(snapshot) && typeof snapshot.limitId === "string"
? snapshot.limitId.trim()
: "";
ids.add(snapshotLimitId || key);
}
}
return [...ids];
}
function mergeSparseSnapshot(
current: JsonObject | undefined,
accountFallback: JsonObject | undefined,
update: JsonObject,
limitId: string,
): JsonObject {
const merged: JsonObject = { ...update, limitId };
// Rolling updates serialize unavailable account metadata as null. Preserve
// only those sparse fields; window and reached-state nulls remain authoritative.
for (const key of SPARSE_ACCOUNT_METADATA_KEYS) {
const previous = current?.[key] ?? accountFallback?.[key];
if (merged[key] == null && previous != null) {
merged[key] = previous;
}
}
return merged;
}
function readLimitId(snapshot: JsonObject): string {
const value = snapshot.limitId;
return typeof value === "string" && value.trim() ? value.trim() : "codex";
}

View File

@@ -1,23 +1,80 @@
// Codex tests cover request plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClient } from "./client.js";
import { readRecentCodexRateLimits } from "./rate-limit-cache.js";
const sharedClientMocks = vi.hoisted(() => ({
abandon: vi.fn(async () => undefined),
createIsolatedCodexAppServerClient: vi.fn(),
getSharedCodexAppServerClient: vi.fn(),
release: vi.fn(),
}));
vi.mock("./shared-client.js", () => ({
...sharedClientMocks,
getLeasedSharedCodexAppServerClient: sharedClientMocks.getSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient: vi.fn(),
leaseSharedCodexAppServerClient: async (...args: unknown[]) => {
let settled = false;
return {
client: await sharedClientMocks.getSharedCodexAppServerClient(...args),
release: () => {
if (!settled) {
settled = true;
sharedClientMocks.release();
}
},
abandon: async () => {
if (!settled) {
settled = true;
await sharedClientMocks.abandon();
}
},
};
},
}));
const { requestCodexAppServerJson } = await import("./request.js");
function resumeResponse(threadId: string) {
return {
thread: {
id: threadId,
sessionId: "session-1",
forkedFromId: null,
preview: "",
ephemeral: false,
modelProvider: "openai",
createdAt: 1,
updatedAt: 1,
status: { type: "idle" },
path: null,
cwd: "/repo",
cliVersion: "0.139.0",
source: "unknown",
agentNickname: null,
agentRole: null,
gitInfo: null,
name: null,
turns: [],
},
model: "gpt-5.5-codex",
modelProvider: "openai",
serviceTier: null,
cwd: "/repo",
instructionSources: [],
approvalPolicy: "never",
approvalsReviewer: "user",
sandbox: { type: "dangerFullAccess" },
permissionProfile: null,
reasoningEffort: null,
};
}
describe("requestCodexAppServerJson sandbox guard", () => {
beforeEach(() => {
sharedClientMocks.createIsolatedCodexAppServerClient.mockReset();
sharedClientMocks.getSharedCodexAppServerClient.mockReset();
sharedClientMocks.release.mockClear();
sharedClientMocks.abandon.mockClear();
});
it("fails closed before raw app-server bypass methods in sandboxed sessions", async () => {
@@ -35,6 +92,29 @@ describe("requestCodexAppServerJson sandbox guard", () => {
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
});
it("uses the explicit agent sandbox for globally scoped session keys", async () => {
await expect(
requestCodexAppServerJson({
method: "command/exec",
requestParams: { command: ["sh", "-lc", "id"] },
config: {
agents: {
list: [
{ id: "main", default: true, sandbox: { mode: "off" } },
{ id: "work", sandbox: { mode: "all" } },
],
},
},
agentId: "work",
sessionKey: "global-session",
}),
).rejects.toThrow(
"Codex-native app-server method `command/exec` is unavailable because OpenClaw sandboxing is active for this session.",
);
expect(sharedClientMocks.getSharedCodexAppServerClient).not.toHaveBeenCalled();
});
it("fails closed before raw app-server bypass methods when exec host=node is active", async () => {
for (const method of ["command/exec", "process/spawn"]) {
await expect(
@@ -65,7 +145,31 @@ describe("requestCodexAppServerJson sandbox guard", () => {
}),
).resolves.toEqual({ ok: true });
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
expect(request).toHaveBeenCalledWith(
"thread/list",
{ limit: 10 },
expect.objectContaining({
timeoutMs: expect.any(Number),
signal: expect.any(AbortSignal),
}),
);
});
it("records full rate-limit reads on the physical control client", async () => {
const snapshot = { rateLimits: { limitId: "codex", primary: { usedPercent: 12 } } };
const client = {
request: vi.fn(async () => snapshot),
} as unknown as CodexAppServerClient;
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue(client);
await expect(
requestCodexAppServerJson({
method: "account/rateLimits/read",
requestParams: undefined,
}),
).resolves.toEqual(snapshot);
expect(readRecentCodexRateLimits(client)).toEqual(snapshot);
});
it("fails closed for config-level exec host=node even without a session key", async () => {
@@ -109,11 +213,125 @@ describe("requestCodexAppServerJson sandbox guard", () => {
}),
).resolves.toEqual({ ok: true });
expect(request).toHaveBeenCalledWith("thread/list", { limit: 10 }, { timeoutMs: 60_000 });
expect(request).toHaveBeenCalledWith(
"thread/list",
{ limit: 10 },
expect.objectContaining({
timeoutMs: expect.any(Number),
signal: expect.any(AbortSignal),
}),
);
});
it("unsubscribes owned resumes but abandons a mismatched response", async () => {
const request = vi
.fn()
.mockResolvedValueOnce(resumeResponse("thread-1"))
.mockResolvedValueOnce({})
.mockRejectedValueOnce(new Error("resume response lost"))
.mockResolvedValueOnce(resumeResponse("wrong-thread"));
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({
request,
addNotificationHandler: vi.fn(() => () => undefined),
});
await expect(
requestCodexAppServerJson({
method: "thread/resume",
requestParams: { threadId: "thread-1" },
}),
).resolves.toMatchObject({ thread: { id: "thread-1" } });
await expect(
requestCodexAppServerJson({
method: "thread/resume",
requestParams: { threadId: "thread-2" },
}),
).rejects.toThrow("resume response lost");
await expect(
requestCodexAppServerJson({
method: "thread/resume",
requestParams: { threadId: "thread-3" },
}),
).rejects.toThrow("Codex thread/resume returned wrong-thread for thread-3");
expect(request.mock.calls.map(([method, params]) => [method, params])).toEqual([
["thread/resume", { threadId: "thread-1" }],
["thread/unsubscribe", { threadId: "thread-1" }],
["thread/resume", { threadId: "thread-2" }],
["thread/resume", { threadId: "thread-3" }],
]);
expect(sharedClientMocks.abandon).toHaveBeenCalledTimes(2);
});
it("does not release a thread owner when the request deadline expires before resume", async () => {
const request = vi.fn();
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
const now = vi.spyOn(Date, "now").mockReturnValueOnce(0).mockReturnValue(10);
await expect(
requestCodexAppServerJson({
method: "thread/resume",
requestParams: { threadId: "thread-1" },
timeoutMs: 1,
}),
).rejects.toThrow("codex app-server thread/resume timed out");
expect(request).not.toHaveBeenCalled();
expect(sharedClientMocks.release).toHaveBeenCalledOnce();
expect(sharedClientMocks.abandon).not.toHaveBeenCalled();
now.mockRestore();
});
it("retires an isolated client that resolves after the end-to-end deadline", async () => {
let resolveClient!: (client: CodexAppServerClient) => void;
sharedClientMocks.createIsolatedCodexAppServerClient.mockImplementationOnce(
async () =>
await new Promise<CodexAppServerClient>((resolve) => {
resolveClient = resolve;
}),
);
const request = vi.fn();
const closeAndWait = vi.fn(async () => undefined);
const response = requestCodexAppServerJson({
method: "model/list",
requestParams: { limit: 10 },
isolated: true,
timeoutMs: 5,
});
await expect(response).rejects.toThrow("codex app-server model/list timed out");
resolveClient({ request, closeAndWait } as unknown as CodexAppServerClient);
await vi.waitFor(() => expect(closeAndWait).toHaveBeenCalledOnce());
expect(request).not.toHaveBeenCalled();
});
it("does not let isolated teardown extend the caller deadline", async () => {
const request = vi.fn(async () => ({ data: [] }));
const closeAndWait = vi.fn(async () => await new Promise<void>(() => {}));
sharedClientMocks.createIsolatedCodexAppServerClient.mockResolvedValue({
request,
closeAndWait,
});
await expect(
requestCodexAppServerJson({
method: "model/list",
requestParams: { limit: 10 },
isolated: true,
timeoutMs: 5,
}),
).rejects.toThrow("codex app-server model/list timed out");
expect(request).toHaveBeenCalledOnce();
expect(closeAndWait).toHaveBeenCalledOnce();
});
it("allows sandbox-pinned thread starts in sandboxed sessions", async () => {
const request = vi.fn(async () => ({ thread: { id: "thread-1" }, model: "gpt-5.5" }));
const request = vi
.fn()
.mockResolvedValueOnce({ thread: { id: "thread-1" }, model: "gpt-5.5" })
.mockResolvedValueOnce({});
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
const params = {
cwd: "/workspace",
@@ -129,7 +347,44 @@ describe("requestCodexAppServerJson sandbox guard", () => {
}),
).resolves.toEqual({ thread: { id: "thread-1" }, model: "gpt-5.5" });
expect(request).toHaveBeenCalledWith("thread/start", params, { timeoutMs: 60_000 });
expect(request.mock.calls).toEqual([
[
"thread/start",
params,
expect.objectContaining({
timeoutMs: expect.any(Number),
signal: expect.any(AbortSignal),
}),
],
["thread/unsubscribe", { threadId: "thread-1" }, { timeoutMs: 5_000 }],
]);
});
it("unsubscribes one-shot shared thread forks", async () => {
const request = vi
.fn()
.mockResolvedValueOnce({ thread: { id: "child-thread" } })
.mockResolvedValueOnce({});
sharedClientMocks.getSharedCodexAppServerClient.mockResolvedValue({ request });
await expect(
requestCodexAppServerJson({
method: "thread/fork",
requestParams: { threadId: "parent-thread" },
}),
).resolves.toEqual({ thread: { id: "child-thread" } });
expect(request.mock.calls).toEqual([
[
"thread/fork",
{ threadId: "parent-thread" },
expect.objectContaining({
timeoutMs: expect.any(Number),
signal: expect.any(AbortSignal),
}),
],
["thread/unsubscribe", { threadId: "child-thread" }, { timeoutMs: 5_000 }],
]);
});
it("blocks thread starts with sandbox environments when exec host=node is active", async () => {

View File

@@ -1,21 +1,30 @@
import {
CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
CodexAppServerUnsafeSubscriptionError,
settleCodexAppServerClientLease,
} from "./attempt-client-cleanup.js";
/**
* Sends typed JSON-RPC requests to the Codex app-server with sandbox guard
* checks, shared-client leasing, and isolated-client shutdown handling.
*/
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
import type { CodexAppServerStartOptions } from "./config.js";
import type {
CodexAppServerRequestMethod,
CodexAppServerRequestParams,
CodexAppServerRequestResult,
JsonValue,
import {
isJsonObject,
readCodexThreadCreationResponseId,
type CodexAppServerRequestMethod,
type CodexAppServerRequestParams,
type CodexAppServerRequestResult,
type CodexThreadResumeParams,
type JsonValue,
} from "./protocol.js";
import { rememberCodexRateLimitsRead } from "./rate-limit-cache.js";
import { resolveCodexAppServerDirectSandboxBypassBlock } from "./sandbox-guard.js";
import {
createIsolatedCodexAppServerClient,
getLeasedSharedCodexAppServerClient,
releaseLeasedSharedCodexAppServerClient,
leaseSharedCodexAppServerClient,
} from "./shared-client.js";
import { resumeCodexAppServerThread } from "./thread-resume.js";
import { withTimeout } from "./timeout.js";
/** Sends a typed Codex app-server request and returns the method-specific response shape. */
@@ -25,6 +34,7 @@ export async function requestCodexAppServerJson<M extends CodexAppServerRequestM
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string | null;
agentId?: string;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sessionKey?: string;
@@ -37,6 +47,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string | null;
agentId?: string;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sessionKey?: string;
@@ -49,6 +60,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
timeoutMs?: number;
startOptions?: CodexAppServerStartOptions;
authProfileId?: string | null;
agentId?: string;
agentDir?: string;
config?: Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0]["config"];
sessionKey?: string;
@@ -59,6 +71,7 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
method: params.method,
requestParams: params.requestParams,
config: params.config,
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
});
@@ -66,33 +79,112 @@ export async function requestCodexAppServerJson<T = JsonValue | undefined>(param
throw new Error(sandboxBlock);
}
const timeoutMs = params.timeoutMs ?? 60_000;
return await withTimeout(
(async () => {
const client = await (
params.isolated ? createIsolatedCodexAppServerClient : getLeasedSharedCodexAppServerClient
)({
startOptions: params.startOptions,
timeoutMs,
authProfileId: params.authProfileId,
agentDir: params.agentDir,
config: params.config,
});
try {
return await client.request<T>(params.method, params.requestParams, { timeoutMs });
} finally {
if (params.isolated) {
// Wait for the child to actually exit (with a SIGKILL fallback) so
// the parent process doesn't hang on an orphaned codex app-server.
// The stdio bin shim does not always propagate stdin EOF to the
// underlying codex binary, so the unref'd close() path can leave
// the child running and keep the parent's event loop alive.
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
const timeoutMessage = `codex app-server ${params.method} timed out`;
const abortController = new AbortController();
const operation = (async () => {
const startedAt = Date.now();
const clientOptions = {
startOptions: params.startOptions,
timeoutMs,
authProfileId: params.authProfileId,
agentDir: params.agentDir,
config: params.config,
abandonSignal: abortController.signal,
};
const clientLease = params.isolated
? undefined
: await leaseSharedCodexAppServerClient(clientOptions);
const client = clientLease?.client ?? (await createIsolatedCodexAppServerClient(clientOptions));
const requestedThreadId =
params.method === "thread/resume" && isJsonObject(params.requestParams)
? typeof params.requestParams.threadId === "string"
? params.requestParams.threadId
: undefined
: undefined;
let subscribedThreadId: string | undefined;
let abandonClient = false;
try {
abortController.signal.throwIfAborted();
const requestTimeoutMs = remainingRequestTimeoutMs(startedAt, timeoutMs, params.method);
let response: T;
if (params.method === "thread/resume" && requestedThreadId) {
subscribedThreadId = requestedThreadId;
response = (await resumeCodexAppServerThread({
client,
abandonClient: clientLease
? clientLease.abandon
: async () =>
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 }),
request: params.requestParams as CodexThreadResumeParams,
timeoutMs: requestTimeoutMs,
signal: abortController.signal,
})) as T;
} else {
response = await client.request<T>(params.method, params.requestParams, {
timeoutMs: requestTimeoutMs,
signal: abortController.signal,
});
}
if (params.method === "account/rateLimits/read") {
rememberCodexRateLimitsRead(client, response as JsonValue | undefined);
}
if (isThreadSubscriptionMethod(params.method)) {
const returnedThreadId = readCodexThreadCreationResponseId(response);
if (!returnedThreadId) {
abandonClient = true;
throw new CodexAppServerUnsafeSubscriptionError(
`Codex ${params.method} response omitted its thread id`,
);
}
if (params.method === "thread/resume") {
if (!requestedThreadId) {
abandonClient = true;
throw new CodexAppServerUnsafeSubscriptionError(
"Codex thread/resume succeeded without a requested thread id",
);
}
} else {
releaseLeasedSharedCodexAppServerClient(client);
subscribedThreadId = returnedThreadId;
}
}
})(),
timeoutMs,
`codex app-server ${params.method} timed out`,
);
return response;
} catch (error) {
abandonClient ||= error instanceof CodexAppServerUnsafeSubscriptionError;
throw error;
} finally {
if (params.isolated) {
// Cleanup may outlive the caller's end-to-end deadline, but the outer
// timeout aborts all work and returns without orphaning the child.
await client.closeAndWait({ exitTimeoutMs: 2_000, forceKillDelayMs: 250 });
} else if (clientLease) {
await settleCodexAppServerClientLease(clientLease, {
threadId: subscribedThreadId,
timeoutMs: CODEX_APP_SERVER_UNSUBSCRIBE_TIMEOUT_MS,
abandon: abandonClient,
});
}
}
})();
try {
return await withTimeout(operation, timeoutMs, timeoutMessage);
} catch (error) {
abortController.abort(error);
void operation.catch(() => undefined);
throw error;
}
}
function remainingRequestTimeoutMs(startedAt: number, timeoutMs: number, method: string): number {
if (timeoutMs <= 0) {
return timeoutMs;
}
const remaining = timeoutMs - (Date.now() - startedAt);
if (remaining <= 0) {
throw new Error(`codex app-server ${method} timed out`);
}
return Math.max(1, remaining);
}
function isThreadSubscriptionMethod(method: string): boolean {
return method === "thread/start" || method === "thread/fork" || method === "thread/resume";
}

View File

@@ -15,19 +15,29 @@ import { clearPluginCommands } from "openclaw/plugin-sdk/plugin-runtime";
import { resolvePreferredOpenClawTmpDir } from "openclaw/plugin-sdk/temp-path";
import { afterEach, beforeEach, expect, vi } from "vitest";
import { defaultCodexAppInventoryCache } from "./app-inventory-cache.js";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import { createCodexDynamicToolBridge } from "./dynamic-tools.js";
import type { CodexServerNotification } from "./protocol.js";
import { resetCodexRateLimitCacheForTests } from "./rate-limit-cache.js";
import type { CodexServerNotification, CodexThread } from "./protocol.js";
import {
runCodexAppServerAttempt as runCodexAppServerAttemptImpl,
testing,
} from "./run-attempt.js";
import { closeCodexSandboxExecServersForTests } from "./sandbox-exec-server.js";
import { createCodexTestModel } from "./test-support.js";
import {
registerCodexTestSessionIdentity,
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
} from "./session-binding.test-helpers.js";
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
import {
adaptCodexTestClientFactory,
createCodexTestModel,
type CodexTestAppServerClientFactory,
} from "./test-support.js";
export let tempDir: string;
let codexAppServerClientFactoryForTest: CodexAppServerClientFactory | undefined;
let codexAppServerClientLeaseFactoryForTest: CodexAppServerClientLeaseFactory | undefined;
const multiplexedTestClients = new WeakSet<CodexAppServerClient>();
export const fastWait = { interval: 1, timeout: 5_000 } as const;
const appServerHarnessWait = { interval: 1, timeout: 120_000 } as const;
const activeAppServerAttemptsForTest = new Set<{
@@ -37,9 +47,12 @@ const activeAppServerAttemptsForTest = new Set<{
sessionKey?: string;
}>();
type RunCodexAppServerAttemptOptions = NonNullable<
type RunCodexAppServerAttemptImplOptions = NonNullable<
Parameters<typeof runCodexAppServerAttemptImpl>[1]
>;
type RunCodexAppServerAttemptOptions = Omit<RunCodexAppServerAttemptImplOptions, "bindingStore"> & {
bindingStore?: RunCodexAppServerAttemptImplOptions["bindingStore"];
};
export function queueActiveRunMessageForTest(
...args: Parameters<typeof queueAgentHarnessMessage>
@@ -47,19 +60,66 @@ export function queueActiveRunMessageForTest(
return queueAgentHarnessMessage(...args);
}
export function setCodexAppServerClientFactoryForTest(factory: CodexAppServerClientFactory): void {
codexAppServerClientFactoryForTest = factory;
export function setCodexAppServerClientFactoryForTest(
factory: CodexTestAppServerClientFactory,
): void {
codexAppServerClientLeaseFactoryForTest = adaptCodexTestClientFactory(async (...args) => {
const client = await factory(...args);
const testClient = client as unknown as {
addCloseHandler?: (handler: () => void) => () => void;
};
// Narrow test doubles still need the client lifecycle hook installed by
// the keyed router, even when the test never simulates transport closure.
testClient.addCloseHandler ??= () => () => undefined;
multiplexTestClientHandlers(client);
return client;
});
}
function multiplexTestClientHandlers(client: CodexAppServerClient): void {
if (multiplexedTestClients.has(client)) {
return;
}
multiplexedTestClients.add(client);
const notificationHandlers = new Set<
Parameters<CodexAppServerClient["addNotificationHandler"]>[0]
>();
const requestHandlers = new Set<Parameters<CodexAppServerClient["addRequestHandler"]>[0]>();
const addNotificationHandler = client.addNotificationHandler.bind(client);
const addRequestHandler = client.addRequestHandler.bind(client);
addNotificationHandler(async (notification) => {
await Promise.all(
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
);
});
addRequestHandler(async (request) => {
for (const handler of requestHandlers) {
const result = await handler(request);
if (result !== undefined) {
return result;
}
}
return undefined;
});
client.addNotificationHandler = (handler) => {
notificationHandlers.add(handler);
return () => notificationHandlers.delete(handler);
};
client.addRequestHandler = (handler) => {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
};
}
function resetCodexAppServerClientFactoryForTest(): void {
codexAppServerClientFactoryForTest = undefined;
codexAppServerClientLeaseFactoryForTest = undefined;
}
export function runCodexAppServerAttempt(
params: EmbeddedRunAttemptParams,
options: RunCodexAppServerAttemptOptions = {},
) {
const clientFactory = options.clientFactory ?? codexAppServerClientFactoryForTest;
const clientLeaseFactory = options.clientLeaseFactory ?? codexAppServerClientLeaseFactoryForTest;
const abortController = params.abortSignal ? undefined : new AbortController();
const trackedParams = abortController
? ({ ...params, abortSignal: abortController.signal } as EmbeddedRunAttemptParams)
@@ -70,10 +130,11 @@ export function runCodexAppServerAttempt(
sessionId: params.sessionId,
sessionKey: params.sessionKey,
};
const promise = runCodexAppServerAttemptImpl(
trackedParams,
clientFactory ? { ...options, clientFactory } : options,
).finally(() => {
const promise = runCodexAppServerAttemptImpl(trackedParams, {
...options,
bindingStore: options.bindingStore ?? testCodexAppServerBindingStore,
...(clientLeaseFactory ? { clientLeaseFactory } : {}),
}).finally(() => {
activeAppServerAttemptsForTest.delete(entry);
});
entry.promise = promise;
@@ -121,6 +182,7 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
}
export function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
registerCodexTestSessionIdentity(sessionFile, "session-1", "agent:main:session-1");
return {
prompt: "hello",
sessionId: "session-1",
@@ -238,7 +300,7 @@ export function threadStartResult(threadId = "thread-1") {
agentRole: null,
gitInfo: null,
name: null,
turns: [],
turns: [] as CodexThread["turns"],
},
model: "gpt-5.4-codex",
modelProvider: "openai",
@@ -301,61 +363,73 @@ export function createAppServerHarness(
} = {},
) {
const requests: Array<{ method: string; params: unknown }> = [];
let notifyHandler: ((notification: CodexServerNotification) => Promise<void>) | undefined;
let handleServerRequest: AppServerRequestHandler | undefined;
const notificationHandlers = new Set<
(notification: CodexServerNotification) => Promise<void> | void
>();
const serverRequestHandlers = new Set<AppServerRequestHandler>();
const closeHandlers = new Set<() => void>();
const request = vi.fn(async (method: string, params?: unknown, requestOptions?: unknown) => {
requests.push({ method, params });
return requestImpl(method, params, requestOptions as { signal?: AbortSignal } | undefined);
});
const client = {
getServerVersion: () => "0.132.0",
request,
addNotificationHandler: (
handler: (notification: CodexServerNotification) => Promise<void> | void,
) => {
notificationHandlers.add(handler);
return () => notificationHandlers.delete(handler);
},
addRequestHandler: (handler: AppServerRequestHandler) => {
serverRequestHandlers.add(handler);
return () => serverRequestHandlers.delete(handler);
},
addCloseHandler: (handler: () => void) => {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
},
} as unknown as CodexAppServerClient;
setCodexAppServerClientFactoryForTest(async (_startOptions, authProfileId, agentDir) => {
options.onStart?.(authProfileId, agentDir);
return {
...mockClientRuntimeMethods(),
request,
addNotificationHandler: (
handler: (notification: CodexServerNotification) => Promise<void>,
) => {
notifyHandler = handler;
return () => {
if (notifyHandler === handler) {
notifyHandler = undefined;
}
};
},
addRequestHandler: (handler: AppServerRequestHandler) => {
handleServerRequest = handler;
return () => undefined;
},
addCloseHandler: (handler: () => void) => {
closeHandlers.add(handler);
return () => closeHandlers.delete(handler);
},
} as never;
return client;
});
const waitForServerRequestHandler = async () => {
await vi.waitFor(() => expect(handleServerRequest).toBeTypeOf("function"), {
await vi.waitFor(() => expect(serverRequestHandlers.size).toBeGreaterThan(0), {
interval: 1,
timeout: appServerHarnessWait.timeout,
});
return handleServerRequest!;
return async (requestLocal: Parameters<AppServerRequestHandler>[0]) => {
for (const handler of serverRequestHandlers) {
const result = await handler(requestLocal);
if (result !== undefined) {
return result;
}
}
return undefined;
};
};
const waitForNotificationHandler = async () => {
await vi.waitFor(() => expect(notifyHandler).toBeTypeOf("function"), {
await vi.waitFor(() => expect(notificationHandlers.size).toBeGreaterThan(0), {
interval: 1,
timeout: appServerHarnessWait.timeout,
});
return notifyHandler!;
return async (notification: CodexServerNotification) => {
await Promise.all(
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
);
};
};
const sendNotification = async (notification: CodexServerNotification) => {
const handler = notifyHandler ?? (await waitForNotificationHandler());
const handler = await waitForNotificationHandler();
await handler(notification);
};
return {
client,
request,
requests,
waitForMethod: async (method: string, timeoutMs: number = appServerHarnessWait.timeout) => {
@@ -428,9 +502,10 @@ export function createStartedThreadHarness(
}
export function createResumeHarness() {
return createAppServerHarness(async (method) => {
return createAppServerHarness(async (method, params) => {
if (method === "thread/resume") {
return threadStartResult("thread-existing");
const threadId = (params as { threadId?: unknown }).threadId;
return threadStartResult(typeof threadId === "string" ? threadId : "thread-existing");
}
if (method === "turn/start") {
return turnStartResult();
@@ -514,6 +589,7 @@ export function setupRunAttemptTestHooks(): void {
clearMemoryPluginState();
resetAgentEventsForTest();
resetDiagnosticEventsForTest();
resetCodexTestBindingStore();
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
@@ -527,7 +603,6 @@ export function setupRunAttemptTestHooks(): void {
testing.resetOpenClawCodingToolsFactoryForTests();
testing.resetEnsuredCodexWorkspaceDirsForTests();
testing.clearPendingCodexNativeHookRelayUnregistersForTests();
resetCodexRateLimitCacheForTests();
nativeHookRelayTesting.clearNativeHookRelaysForTests();
clearMemoryPluginState();
clearPluginCommands();

View File

@@ -7,10 +7,35 @@ import {
type EmbeddedRunAttemptParams,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import { CodexAppServerRpcError } from "./client.js";
import type { CodexServerNotification } from "./protocol.js";
import { runCodexAppServerAttempt } from "./run-attempt.js";
import { createCodexTestModel } from "./test-support.js";
import { createCodexTestBindingStore } from "./session-binding.test-helpers.js";
import {
adaptCodexTestClientFactory,
createCodexTestModel,
type CodexTestAppServerClientFactory,
} from "./test-support.js";
const configRuntimeMock = vi.hoisted(() => ({ rejectedProvider: undefined as string | undefined }));
vi.mock("./config.js", async (importOriginal) => {
const actual = await importOriginal<typeof import("./config.js")>();
return {
...actual,
resolveCodexAppServerRuntime: (
params: Parameters<typeof actual.resolveCodexAppServerRuntime>[0],
) => {
if (
configRuntimeMock.rejectedProvider &&
params?.modelProvider === configRuntimeMock.rejectedProvider
) {
throw new Error(`rejected active provider: ${params.modelProvider}`);
}
return actual.resolveCodexAppServerRuntime(params);
},
};
});
let tempDir: string;
@@ -105,6 +130,7 @@ describe("Codex app-server main thread cleanup", () => {
vi.stubEnv("OPENCLAW_TRAJECTORY", "0");
vi.stubEnv("CODEX_API_KEY", "");
vi.stubEnv("OPENAI_API_KEY", "");
configRuntimeMock.rejectedProvider = undefined;
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-run-cleanup-"));
});
@@ -120,7 +146,9 @@ describe("Codex app-server main thread cleanup", () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const requests: Array<{ method: string; params: unknown }> = [];
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const notificationHandlers = new Set<
(notification: CodexServerNotification) => Promise<void> | void
>();
const request = vi.fn(async (method: string, params?: unknown) => {
requests.push({ method, params });
if (method === "thread/start") {
@@ -132,33 +160,37 @@ describe("Codex app-server main thread cleanup", () => {
return {};
});
const clientFactory: CodexAppServerClientFactory = async () => {
const clientFactory: CodexTestAppServerClientFactory = async () => {
return {
...mockClientRuntimeMethods(),
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
addNotificationHandler: (handler: (notification: CodexServerNotification) => void) => {
notificationHandlers.add(handler);
return () => notificationHandlers.delete(handler);
},
addRequestHandler: () => () => undefined,
addCloseHandler: () => () => undefined,
} as never;
};
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
clientFactory,
bindingStore: createCodexTestBindingStore(),
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
});
await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain("turn/start"), {
interval: 1,
timeout: 5_000,
});
await notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
for (const handler of notificationHandlers) {
await handler({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
}
const result = await run;
expect(result.aborted).toBe(false);
@@ -189,20 +221,28 @@ describe("Codex app-server main thread cleanup", () => {
return {};
});
const clientFactory: CodexAppServerClientFactory = async () => {
const clientFactory: CodexTestAppServerClientFactory = async () => {
return {
...mockClientRuntimeMethods(),
request,
addNotificationHandler: () => () => undefined,
addRequestHandler: () => () => undefined,
addCloseHandler: () => () => undefined,
} as never;
};
const abandon = vi.fn(async () => undefined);
await expect(
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
clientFactory,
bindingStore: createCodexTestBindingStore(),
clientLeaseFactory: async () => ({
client: await clientFactory(),
release: () => undefined,
abandon,
}),
}),
).rejects.toThrow("turn start exploded");
expect(abandon).toHaveBeenCalledOnce();
expect(requests.map((entry) => entry.method)).toEqual([
"thread/start",
"turn/start",
@@ -214,4 +254,162 @@ describe("Codex app-server main thread cleanup", () => {
{ timeoutMs: 5_000 },
);
});
it("releases startup ownership when authoritative provider policy rejects", async () => {
const sessionFile = path.join(tempDir, "session-policy-rejection.jsonl");
const workspaceDir = path.join(tempDir, "workspace-policy-rejection");
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
const response = threadStartResult();
return {
...response,
thread: { ...response.thread, modelProvider: "lmstudio" },
model: "local-model",
modelProvider: "lmstudio",
};
}
if (method === "thread/unsubscribe") {
return {};
}
throw new Error(`unexpected method: ${method}`);
});
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
configRuntimeMock.rejectedProvider = "lmstudio";
await expect(
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
bindingStore: createCodexTestBindingStore(),
clientLeaseFactory: async () => ({
client: {
request,
addNotificationHandler: () => () => undefined,
addRequestHandler: () => () => undefined,
addCloseHandler: () => () => undefined,
} as never,
release,
abandon,
}),
}),
).rejects.toThrow("rejected active provider: lmstudio");
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
{ threadId: "thread-1" },
{ timeoutMs: 5_000 },
);
expect(release).toHaveBeenCalledOnce();
expect(abandon).not.toHaveBeenCalled();
});
it("keeps the main client reusable after a structured turn rejection", async () => {
const sessionFile = path.join(tempDir, "session-rpc-rejection.jsonl");
const workspaceDir = path.join(tempDir, "workspace-rpc-rejection");
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult();
}
if (method === "turn/start") {
throw new CodexAppServerRpcError({ code: -32000, message: "turn rejected" }, method);
}
return {};
});
const abandon = vi.fn(async () => undefined);
await expect(
runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
bindingStore: createCodexTestBindingStore(),
clientLeaseFactory: async () => ({
client: {
request,
addNotificationHandler: () => () => undefined,
addRequestHandler: () => () => undefined,
addCloseHandler: () => () => undefined,
} as never,
release: () => undefined,
abandon,
}),
}),
).rejects.toThrow("turn rejected");
expect(abandon).not.toHaveBeenCalled();
expect(request).toHaveBeenCalledWith(
"thread/unsubscribe",
{ threadId: "thread-1" },
{ timeoutMs: 5_000 },
);
});
it("reuses one client router after each attempt releases its thread route", async () => {
const sessionFile = path.join(tempDir, "session-reused.jsonl");
const workspaceDir = path.join(tempDir, "workspace-reused");
const bindingStore = createCodexTestBindingStore();
const notificationHandlers = new Set<
(notification: CodexServerNotification) => Promise<void> | void
>();
const requestHandlers = new Set<(request: unknown) => unknown>();
let turnIndex = 0;
const request = vi.fn(async (method: string) => {
if (method === "thread/start" || method === "thread/resume") {
return threadStartResult();
}
if (method === "turn/start") {
turnIndex += 1;
return turnStartResult(`turn-${turnIndex}`);
}
return {};
});
const addNotificationHandler = vi.fn(
(handler: (notification: CodexServerNotification) => Promise<void> | void) => {
notificationHandlers.add(handler);
return () => notificationHandlers.delete(handler);
},
);
const addRequestHandler = vi.fn((handler: (request: unknown) => unknown) => {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
});
const client = {
request,
addNotificationHandler,
addRequestHandler,
addCloseHandler: () => () => undefined,
};
const clientFactory: CodexTestAppServerClientFactory = async () => client as never;
const runAttempt = async (turnId: string, expectedTurnStartCount: number) => {
const run = runCodexAppServerAttempt(createParams(sessionFile, workspaceDir), {
bindingStore,
clientLeaseFactory: adaptCodexTestClientFactory(clientFactory),
});
await vi.waitFor(
() =>
expect(request.mock.calls.filter(([method]) => method === "turn/start")).toHaveLength(
expectedTurnStartCount,
),
{ interval: 1, timeout: 5_000 },
);
for (const handler of notificationHandlers) {
void handler({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId,
turn: { id: turnId, threadId: "thread-1", status: "completed" },
},
});
}
return await run;
};
await expect(runAttempt("turn-1", 1)).resolves.toMatchObject({ aborted: false });
const notificationHandlerCount = addNotificationHandler.mock.calls.length;
const requestHandlerCount = addRequestHandler.mock.calls.length;
await expect(runAttempt("turn-2", 2)).resolves.toMatchObject({ aborted: false });
expect(addNotificationHandler).toHaveBeenCalledTimes(notificationHandlerCount);
expect(addRequestHandler).toHaveBeenCalledTimes(requestHandlerCount);
expect(notificationHandlers.size).toBe(notificationHandlerCount);
expect(requestHandlers.size).toBe(requestHandlerCount);
});
});

View File

@@ -9,44 +9,71 @@ import {
type HarnessContextEngine as ContextEngine,
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { SessionManager } from "openclaw/plugin-sdk/agent-sessions";
import {
initializeGlobalHookRunner,
resetGlobalHookRunner,
} from "openclaw/plugin-sdk/hook-runtime";
import { MESSAGE_TOOL_DELIVERY_HINTS } from "openclaw/plugin-sdk/message-tool-delivery-hints";
import { createMockPluginRegistry } from "openclaw/plugin-sdk/plugin-test-runtime";
import { registerSandboxBackend } from "openclaw/plugin-sdk/sandbox";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexAppServerClientFactory } from "./client-factory.js";
import type { CodexAppServerClient } from "./client.js";
import type { CodexServerNotification } from "./protocol.js";
import { runCodexAppServerAttempt as runCodexAppServerAttemptImpl } from "./run-attempt.js";
import {
clearCodexAppServerBinding,
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
import { createCodexTestModel } from "./test-support.js";
registerCodexTestSessionIdentity,
resetCodexTestBindingStore,
testCodexAppServerBindingStore,
writeCodexAppServerBinding as writeStoredCodexAppServerBinding,
} from "./session-binding.test-helpers.js";
import type { CodexAppServerClientLeaseFactory } from "./shared-client.js";
import {
adaptCodexTestClientFactory,
createCodexTestModel,
type CodexTestAppServerClientFactory,
} from "./test-support.js";
let tempDir: string;
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;
}
async function writeCodexAppServerBinding(
...args: Parameters<typeof writeStoredCodexAppServerBinding>
): Promise<void> {
registerCodexTestSessionIdentity(args[0], "session-1", "agent:main:session-1");
await writeStoredCodexAppServerBinding(...args);
}
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, "session-1", "agent:main:session-1");
return {
prompt: "hello",
sessionId: "session-1",
@@ -71,9 +98,7 @@ const DISABLED_CODEX_WEB_SEARCH_THREAD_CONFIG_FINGERPRINT = JSON.stringify({
web_search: "disabled",
});
function writeCodexAppServerBinding(
...args: Parameters<typeof writeRawCodexAppServerBinding>
) {
function writeCodexAppServerBinding(...args: Parameters<typeof writeRawCodexAppServerBinding>) {
const [sessionFile, binding, lookup] = args;
return writeRawCodexAppServerBinding(
sessionFile,
@@ -198,7 +223,10 @@ function createStartedThreadHarness(
requestImpl: (method: string, params: unknown) => Promise<unknown> = async () => undefined,
) {
const requests: Array<{ method: string; params: unknown }> = [];
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const notificationHandlers = new Set<
(notification: CodexServerNotification) => Promise<void> | void
>();
const requestHandlers = new Set<(request: unknown) => unknown>();
const request = vi.fn(async (method: string, params?: unknown) => {
requests.push({ method, params });
const override = await requestImpl(method, params);
@@ -214,21 +242,37 @@ function createStartedThreadHarness(
return {};
});
setCodexAppServerClientFactoryForTest(
async () =>
({
...mockClientRuntimeMethods(),
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: () => () => undefined,
}) as never,
);
const client = {
request,
addNotificationHandler: (
handler: (notification: CodexServerNotification) => Promise<void> | void,
) => {
notificationHandlers.add(handler);
return () => notificationHandlers.delete(handler);
},
addRequestHandler: (handler: (request: unknown) => unknown) => {
requestHandlers.add(handler);
return () => requestHandlers.delete(handler);
},
addCloseHandler: () => () => undefined,
} as unknown as CodexAppServerClient;
setCodexAppServerClientFactoryForTest(async () => client);
const notify = async (notification: CodexServerNotification) => {
await Promise.all(
[...notificationHandlers].map((handler) => Promise.resolve(handler(notification))),
);
};
return {
client,
requests,
async handleServerRequest(serverRequest: unknown) {
const responses = await Promise.all(
[...requestHandlers].map((handler) => Promise.resolve(handler(serverRequest))),
);
return responses[0];
},
async waitForMethod(method: string) {
await vi.waitFor(() => expect(requests.map((entry) => entry.method)).toContain(method), {
interval: 1,
@@ -344,11 +388,13 @@ function getRequestInputTextAt(
describe("runCodexAppServerAttempt context-engine lifecycle", () => {
beforeEach(async () => {
resetCodexTestBindingStore();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-context-engine-"));
});
afterEach(async () => {
resetCodexAppServerClientFactoryForTest();
resetGlobalHookRunner();
vi.restoreAllMocks();
await fs.rm(tempDir, { recursive: true, force: true });
});
@@ -493,6 +539,134 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await run;
});
it("bounds active context-engine projections when prompt hooks append context", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_prompt_build",
handler: async (event) => ({
appendContext: `${(event as { prompt: string }).prompt}\n\nhook append marker`,
prependContext: "hook prefix context",
}),
},
]),
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const contextEngine = createContextEngine({
assemble: vi.fn(async () => ({
messages: [
...Array.from({ length: 9 }, (_, index) =>
assistantMessage(`older context ${index} ${"x".repeat(120_000)}`, index),
),
assistantMessage("recent anchor", 10),
],
estimatedTokens: 300_000,
})),
});
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
params.contextTokenBudget = 300_000;
params.prompt = "current prompt survives";
params.currentInboundContext = { text: "current inbound context survives" };
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
const inputText = getRequestInputText(harness);
expect(inputText.length).toBe(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
expect(inputText).toContain("recent anchor");
expect(inputText).toContain("current inbound context survives");
expect(inputText).toContain("current prompt survives");
expect(inputText).toContain("hook append marker");
await harness.completeTurn();
await run;
});
it("bounds hook-appended prompts without an active context engine", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_prompt_build",
handler: async () => ({ appendContext: `hook context ${"h".repeat(1_100_000)}` }),
},
]),
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.prompt = "current prompt survives";
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
const inputText = getRequestInputText(harness);
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
expect(inputText).toContain("current prompt survives");
expect(inputText).not.toContain("hook context");
await harness.completeTurn();
await run;
});
it("bounds hook-appended prompts after delivery metadata is relocated", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_prompt_build",
handler: async () => ({ appendContext: `hook context ${"h".repeat(1_100_000)}` }),
},
]),
);
const sessionFile = path.join(tempDir, "session-delivery-hint.jsonl");
const workspaceDir = path.join(tempDir, "workspace-delivery-hint");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.prompt = `${MESSAGE_TOOL_DELIVERY_HINTS[0]}\n\ncurrent prompt survives`;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
const inputText = getRequestInputText(harness);
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
expect(inputText).toContain("Current user request:\ncurrent prompt survives");
expect(inputText).not.toContain("hook context");
await harness.completeTurn();
await run;
});
it("bounds hook-appended output for an empty prompt without an active context engine", async () => {
initializeGlobalHookRunner(
createMockPluginRegistry([
{
hookName: "before_prompt_build",
handler: async () => ({
appendContext: `hook context ${"h".repeat(1_100_000)} hook tail`,
}),
},
]),
);
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
const params = createParams(sessionFile, workspaceDir);
params.prompt = "";
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
const inputText = getRequestInputText(harness);
expect(inputText.length).toBeLessThanOrEqual(CODEX_TURN_START_TEXT_INPUT_MAX_CHARS);
expect(inputText).toContain("hook tail");
await harness.completeTurn();
await run;
});
it("uses configured compaction reserve when sizing Codex context-engine projections", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -611,13 +785,14 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await secondRun;
});
it("resumes a matching thread-bootstrap binding even when the bootstrap turn exceeded the opt-in native byte guard", async () => {
it("resumes a matching thread-bootstrap binding without a native usage snapshot", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const agentDir = path.join(tempDir, "agent");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-bootstrapped",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
@@ -631,21 +806,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
},
},
});
await fs.writeFile(
path.join(path.dirname(sessionFile), "sessions.json"),
JSON.stringify({
"agent:main:session-1": {
sessionFile,
totalTokens: 12_000,
},
}),
);
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
await fs.mkdir(rolloutDir, { recursive: true });
await fs.writeFile(
path.join(rolloutDir, "rollout-thread-bootstrapped.jsonl"),
"x".repeat(2_000),
);
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ prompt }) => ({
messages: [
@@ -657,28 +817,11 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
})),
});
const harness = createStartedThreadHarness(async (method) => {
if (method === "thread/resume") {
return threadStartResult("thread-bootstrapped");
}
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
return undefined;
});
const harness = createStartedThreadHarness(async (method) =>
method === "thread/resume" ? threadStartResult("thread-bootstrapped") : undefined,
);
const params = createParams(sessionFile, workspaceDir);
params.agentDir = agentDir;
params.contextEngine = contextEngine;
params.config = {
agents: {
defaults: {
compaction: {
truncateAfterCompaction: true,
maxActiveTranscriptBytes: 1_000,
},
},
},
} as EmbeddedRunAttemptParams["config"];
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
@@ -696,12 +839,11 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await run;
});
it("starts a fresh thread instead of resuming a token-pressured thread-bootstrap binding", async () => {
it("projects assembled context when the binding changes during startup", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const agentDir = path.join(tempDir, "agent");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-bootstrapped",
threadId: "thread-old",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
contextEngine: {
@@ -716,31 +858,256 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
},
},
});
await fs.writeFile(
path.join(path.dirname(sessionFile), "sessions.json"),
JSON.stringify({
"agent:main:session-1": {
sessionFile,
totalTokens: 12_000,
},
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ prompt }) => {
// Simulate a concurrent /codex resume or reset after the initial read.
await clearCodexAppServerBinding(sessionFile);
return {
messages: [
assistantMessage("assembled startup context", 10),
userMessage(prompt ?? "", 11),
],
estimatedTokens: 42,
systemPromptAddition: "context-engine system",
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
};
}),
});
const harness = createStartedThreadHarness(async (method) =>
method === "thread/start" ? threadStartResult("thread-fresh") : undefined,
);
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
await fs.mkdir(rolloutDir, { recursive: true });
await fs.writeFile(
path.join(rolloutDir, "rollout-thread-bootstrapped.jsonl"),
`${JSON.stringify({
payload: {
type: "token_count",
info: {
last_token_usage: {
total_tokens: 241_198,
},
model_context_window: 258_400,
},
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/start",
"turn/start",
]);
expectRequestInputTextContains(harness, "assembled startup context");
await harness.completeTurn("completed", "thread-fresh");
await run;
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
threadId: "thread-fresh",
contextEngine: {
projection: { mode: "thread_bootstrap", epoch: "epoch-1" },
},
});
});
it("awaits accepted-turn interruption and retires the client when it cannot be confirmed", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ prompt }) => ({
messages: [assistantMessage("projected context", 10), userMessage(prompt ?? "", 11)],
estimatedTokens: 42,
contextProjection: { mode: "thread_bootstrap" as const, epoch: "epoch-1" },
})),
});
let rejectTurnInterrupt!: (reason?: unknown) => void;
const turnInterrupt = new Promise<unknown>((_resolve, reject) => {
rejectTurnInterrupt = reject;
});
const harness = createStartedThreadHarness(async (method) => {
if (method === "thread/start") {
return threadStartResult("thread-fresh");
}
if (method === "turn/start") {
return turnStartResult("turn-fresh");
}
if (method === "turn/interrupt") {
return await turnInterrupt;
}
return undefined;
});
let releaseProjectionCommit!: () => void;
let markProjectionCommitStarted!: () => void;
const projectionCommitStarted = new Promise<void>((resolve) => {
markProjectionCommitStarted = resolve;
});
const projectionCommitReleased = new Promise<void>((resolve) => {
releaseProjectionCommit = resolve;
});
const bindingStore: RunCodexAppServerAttemptImplOptions["bindingStore"] = {
...testCodexAppServerBindingStore,
async mutate(identity, mutation) {
if (
mutation.kind === "patch" &&
mutation.patch.contextEngine?.projection?.epoch === "epoch-1"
) {
markProjectionCommitStarted();
await projectionCommitReleased;
return false;
}
return await testCodexAppServerBindingStore.mutate(identity, mutation);
},
};
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
const onAgentEvent = vi.fn();
params.onAgentEvent = onAgentEvent;
const releaseClient = vi.fn();
const abandonClient = vi.fn(async () => undefined);
const run = runCodexAppServerAttempt(params, {
bindingStore,
clientLeaseFactory: async () => ({
client: harness.client,
release: releaseClient,
abandon: abandonClient,
}),
});
await projectionCommitStarted;
const bufferedToolRequest = harness.handleServerRequest({
id: "tool-after-accepted-turn",
method: "item/tool/call",
params: {
threadId: "thread-fresh",
turnId: "turn-fresh",
callId: "call-1",
namespace: null,
tool: "python",
arguments: { code: "print('must not run')" },
},
});
releaseProjectionCommit();
await harness.waitForMethod("turn/interrupt");
const runSettled = vi.fn();
void run.then(runSettled, runSettled);
await new Promise((resolve) => {
setImmediate(resolve);
});
expect(runSettled).not.toHaveBeenCalled();
expect(abandonClient).not.toHaveBeenCalled();
rejectTurnInterrupt(new Error("interrupt response lost"));
await expect(run).rejects.toThrow("binding changed before context projection commit");
await expect(bufferedToolRequest).resolves.toBeUndefined();
expect(abandonClient).toHaveBeenCalledOnce();
expect(releaseClient).not.toHaveBeenCalled();
expect(onAgentEvent).not.toHaveBeenCalledWith(expect.objectContaining({ stream: "tool" }));
expect(
harness.requests.some(
(request) =>
request.method === "turn/interrupt" &&
(request.params as { threadId?: string; turnId?: string }).threadId === "thread-fresh" &&
(request.params as { threadId?: string; turnId?: string }).turnId === "turn-fresh",
),
).toBe(true);
});
it("invalidates the projection and preserves fresh usage after native compaction", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-bootstrapped",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
nativeContextUsage: { currentTokens: 220_000 },
modelContextWindow: 258_400,
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
fingerprint: "fingerprint-1",
},
})}\n`,
},
});
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ prompt }) => ({
messages: [userMessage(prompt ?? "", 11)],
estimatedTokens: 42,
systemPromptAddition: "context-engine system",
contextProjection: {
mode: "thread_bootstrap" as const,
epoch: "epoch-1",
fingerprint: "fingerprint-1",
},
})),
});
const harness = createStartedThreadHarness(async (method) =>
method === "thread/resume" ? threadStartResult("thread-bootstrapped") : undefined,
);
const params = createParams(sessionFile, workspaceDir);
params.contextEngine = contextEngine;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
await harness.notify({
method: "item/started",
params: {
threadId: "thread-bootstrapped",
turnId: "turn-1",
item: { id: "compact-1", type: "contextCompaction" },
},
});
const startedBinding = await readCodexAppServerBinding(sessionFile);
expect(startedBinding).not.toHaveProperty("nativeContextUsage");
expect(startedBinding?.contextEngine).not.toHaveProperty("projection");
await harness.notify({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-bootstrapped",
turnId: "turn-1",
tokenUsage: {
total: { totalTokens: 900_000 },
last: { totalTokens: 12_000 },
modelContextWindow: null,
},
},
});
await harness.notify({
method: "item/completed",
params: {
threadId: "thread-bootstrapped",
turnId: "turn-1",
item: { id: "compact-1", type: "contextCompaction" },
},
});
const compactedBinding = await readCodexAppServerBinding(sessionFile);
expect(compactedBinding).not.toHaveProperty("nativeContextUsage");
await harness.completeTurn("completed", "thread-bootstrapped");
await run;
const binding = await readCodexAppServerBinding(sessionFile);
expect(binding?.contextEngine?.projection).toBeUndefined();
expect(binding?.nativeContextUsage).toEqual({
currentTokens: 12_000,
});
expect(binding?.modelContextWindow).toBe(258_400);
});
it("starts a fresh thread instead of resuming a token-pressured thread-bootstrap binding", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-bootstrapped",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
nativeContextUsage: { currentTokens: 241_198 },
modelContextWindow: 258_400,
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
policyFingerprint:
'{"schemaVersion":1,"engineId":"lossless-claw","ownsCompaction":true,"projectionMaxChars":24000}',
projection: {
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-1",
},
},
});
const contextEngine = createContextEngine({
assemble: vi.fn(async ({ prompt }) => ({
messages: [assistantMessage("reprojected context", 10), userMessage(prompt ?? "", 11)],
@@ -759,13 +1126,14 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.agentDir = agentDir;
params.contextEngine = contextEngine;
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/resume",
"thread/unsubscribe",
"thread/start",
"turn/start",
]);
@@ -780,7 +1148,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
it("does not inject mirrored history when a stale thread-bootstrap binding has no active context engine", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const agentDir = path.join(tempDir, "agent");
const sessionManager = SessionManager.open(sessionFile);
sessionManager.appendMessage(
userMessage("previous stale-bootstrap request", Date.now()) as never,
@@ -792,6 +1159,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
threadId: "thread-stale-bootstrap",
cwd: workspaceDir,
dynamicToolsFingerprint: "[]",
nativeContextUsage: { currentTokens: 300_000 },
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
@@ -804,30 +1172,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
},
},
});
await fs.writeFile(
path.join(path.dirname(sessionFile), "sessions.json"),
JSON.stringify({
"agent:main:session-1": {
sessionFile,
totalTokens: 12_000,
},
}),
);
const rolloutDir = path.join(agentDir, "codex-home", "sessions");
await fs.mkdir(rolloutDir, { recursive: true });
await fs.writeFile(
path.join(rolloutDir, "rollout-thread-stale-bootstrap.jsonl"),
`${JSON.stringify({
payload: {
type: "token_count",
info: {
last_token_usage: {
total_tokens: 300_000,
},
},
},
})}\n`,
);
const harness = createStartedThreadHarness(async (method) => {
if (method === "thread/resume") {
return threadStartResult("thread-stale-bootstrap");
@@ -838,17 +1182,6 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.agentDir = agentDir;
params.config = {
agents: {
defaults: {
compaction: {
truncateAfterCompaction: true,
maxActiveTranscriptBytes: "1mb",
},
},
},
} as EmbeddedRunAttemptParams["config"];
const run = runCodexAppServerAttempt(params);
await harness.waitForMethod("turn/start");
@@ -996,7 +1329,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
sessionId: "session-1",
threadId: "thread-new",
engineId: "lossless-claw",
epoch: "epoch-new",
projectionPending: true,
action: "rotated",
}),
);
@@ -1008,6 +1341,8 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
contextEngine: {
schemaVersion: 1,
@@ -1174,6 +1509,12 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
},
});
await run;
await expect(readCodexAppServerBinding(sessionFile)).resolves.toMatchObject({
threadId: "thread-old",
contextEngine: {
projection: { mode: "thread_bootstrap", epoch: "epoch-1" },
},
});
} finally {
restoreSandboxBackend();
}
@@ -1255,7 +1596,11 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-old",
cwd: workspaceDir,
model: "gpt-5.4-codex",
modelProvider: "openai",
dynamicToolsFingerprint: "[]",
nativeContextUsage: { currentTokens: 120_000 },
modelContextWindow: 258_400,
contextEngine: {
schemaVersion: 1,
engineId: "lossless-claw",
@@ -1302,7 +1647,13 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
throw new Error("Codex ran out of room in the model's context window");
}
if (method === "thread/start") {
return threadStartResult("thread-fresh");
const response = threadStartResult("thread-fresh");
return {
...response,
thread: { ...response.thread, modelProvider: "lmstudio" },
model: "local-model",
modelProvider: "lmstudio",
};
}
if (method === "turn/start" && request.threadId === "thread-fresh") {
return turnStartResult("turn-fresh");
@@ -1313,15 +1664,30 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
params.contextEngine = contextEngine;
params.contextTokenBudget = 400_000;
const run = runCodexAppServerAttempt(params);
const run = runCodexAppServerAttempt(params, {
pluginConfig: { appServer: { mode: "guardian" } },
});
await vi.waitFor(() =>
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/resume",
"turn/start",
"thread/unsubscribe",
"thread/start",
"turn/start",
]),
);
await harness.notify({
method: "thread/tokenUsage/updated",
params: {
threadId: "thread-fresh",
turnId: "turn-fresh",
tokenUsage: {
total: { totalTokens: 900_000 },
last: { totalTokens: 12_000 },
modelContextWindow: null,
},
},
});
await harness.notify({
method: "turn/completed",
params: {
@@ -1337,15 +1703,36 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
const result = await run;
expect(result.assistantTexts).toContain("fresh answer");
const turnStarts = harness.requests.filter((request) => request.method === "turn/start");
expect(turnStarts[0]?.params).toMatchObject({ approvalsReviewer: "auto_review" });
expect(turnStarts[1]?.params).toMatchObject({
model: "local-model",
approvalsReviewer: "user",
approvalPolicy: "on-request",
});
expect(compact).not.toHaveBeenCalled();
expect(assemble).toHaveBeenCalledTimes(1);
const retryInputText = getRequestInputTextAt(harness, -1);
expect(retryInputText).toBe("hello");
expect(retryInputText).toContain("context epoch-before");
expect(retryInputText).toContain("hello");
expect(retryInputText).not.toContain("successor compacted context");
const savedBinding = await readCodexAppServerBinding(sessionFile);
expect(savedBinding?.threadId).toBe("thread-fresh");
expect(savedBinding?.contextEngine?.engineId).toBe("lossless-claw");
expect(savedBinding?.contextEngine?.projection).toBeUndefined();
expect(savedBinding?.contextEngine?.projection).toEqual({
schemaVersion: 1,
mode: "thread_bootstrap",
epoch: "epoch-before",
});
expect(savedBinding?.nativeContextUsage).toEqual({
currentTokens: 12_000,
});
expect(savedBinding?.modelContextWindow).toBeUndefined();
expect(
harness.requests
.filter((request) => request.method === "thread/unsubscribe")
.map((request) => request.params),
).toEqual([{ threadId: "thread-old" }, { threadId: "thread-fresh" }]);
});
it("preserves a newer context-engine binding when a stale resumed thread overflows", async () => {
@@ -1636,6 +2023,7 @@ describe("runCodexAppServerAttempt context-engine lifecycle", () => {
expect(harness.requests.map((request) => request.method)).toEqual([
"thread/resume",
"turn/start",
"thread/unsubscribe",
"thread/start",
"turn/start",
]),

View File

@@ -178,6 +178,8 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
phase?: string;
startedAt?: number;
text?: string;
threadId?: string;
turnId?: string;
};
stream: string;
}>;
@@ -185,6 +187,11 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
(event) => event.stream === "lifecycle" && event.data.phase === "start",
);
expect(typeof lifecycleStart?.data.startedAt).toBe("number");
const turnAccepted = agentEvents.find(
(event) =>
event.stream === "codex_app_server.lifecycle" && event.data.phase === "turn_accepted",
);
expect(turnAccepted?.data).toMatchObject({ threadId: "thread-1", turnId: "turn-1" });
const assistantEvent = agentEvents.find((event) => event.stream === "assistant");
expect(assistantEvent?.data).toEqual({ text: "hello back" });
const lifecycleEnd = agentEvents.find(
@@ -196,10 +203,16 @@ describe("runCodexAppServerAttempt hooks and model diagnostics", () => {
(event) => event.stream === "lifecycle" && event.data.phase === "start",
);
const assistantIndex = agentEvents.findIndex((event) => event.stream === "assistant");
const acceptedIndex = agentEvents.findIndex(
(event) =>
event.stream === "codex_app_server.lifecycle" && event.data.phase === "turn_accepted",
);
const endIndex = agentEvents.findIndex(
(event) => event.stream === "lifecycle" && event.data.phase === "end",
);
expect(startIndex).toBeGreaterThanOrEqual(0);
expect(acceptedIndex).toBeGreaterThanOrEqual(0);
expect(startIndex).toBeGreaterThan(acceptedIndex);
expect(assistantIndex).toBeGreaterThan(startIndex);
expect(endIndex).toBeGreaterThan(assistantIndex);
const globalAssistantEvent = globalAgentEvents.find((event) => event.stream === "assistant");

View File

@@ -7,6 +7,7 @@ import {
} from "openclaw/plugin-sdk/agent-harness-runtime";
import { describe, expect, it, vi } from "vitest";
import * as approvalBridge from "./approval-bridge.js";
import { CodexAppServerRpcError } from "./client.js";
import {
createParams,
createResumeHarness,
@@ -16,12 +17,21 @@ import {
runCodexAppServerAttempt,
setupRunAttemptTestHooks,
tempDir,
threadStartResult,
} from "./run-attempt-test-harness.js";
import { testing } from "./run-attempt.js";
import {
readCodexAppServerBinding,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
registerCodexTestSessionIdentity,
writeCodexAppServerBinding as writeStoredCodexAppServerBinding,
} from "./session-binding.test-helpers.js";
async function writeCodexAppServerBinding(
...args: Parameters<typeof writeStoredCodexAppServerBinding>
): Promise<void> {
registerCodexTestSessionIdentity(args[0], "session-1", "agent:main:session-1");
await writeStoredCodexAppServerBinding(...args);
}
setupRunAttemptTestHooks();
@@ -274,7 +284,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
});
it("lets Codex app-server approval modes own native permission requests by default", async () => {
it("installs policy-stable native hook relay events before thread policy is known", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness();
@@ -295,11 +305,16 @@ describe("runCodexAppServerAttempt native hook relay", () => {
expect(Array.isArray(startConfig?.["hooks.PreToolUse"])).toBe(true);
expect(startConfig?.["hooks.PostToolUse"]).toEqual([]);
expect(startConfig?.["hooks.Stop"]).toEqual([]);
expect(startConfig).not.toHaveProperty("hooks.PermissionRequest");
const permissionRequestHooks = startConfig?.["hooks.PermissionRequest"] as
| Array<{ hooks?: Array<{ command?: string }> }>
| undefined;
expect(permissionRequestHooks?.[0]?.hooks?.[0]?.command).toContain(
"--event permission_request",
);
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
expect(
nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)?.allowedEvents,
).toEqual(["pre_tool_use", "post_tool_use", "before_agent_finalize"]);
).toEqual(["pre_tool_use", "post_tool_use", "permission_request", "before_agent_finalize"]);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
@@ -307,6 +322,68 @@ describe("runCodexAppServerAttempt native hook relay", () => {
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(relayId)).toBeUndefined();
});
it("defers permission hooks after Codex returns a provider with guarded policy", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const harness = createStartedThreadHarness(async (method) => {
if (method !== "thread/start") {
return undefined;
}
const response = threadStartResult();
return {
...response,
thread: { ...response.thread, modelProvider: "lmstudio" },
model: "local-model",
modelProvider: "lmstudio",
};
});
const approvalRequester = vi.fn(async () => "allow" as const);
nativeHookRelayTesting.setNativeHookRelayPermissionApprovalRequesterForTests(approvalRequester);
const params = createParams(sessionFile, workspaceDir);
params.modelId = "openai/gpt-5.4-codex";
const run = runCodexAppServerAttempt(params, {
pluginConfig: {
appServer: {
mode: "yolo",
approvalsReviewer: "auto_review",
},
},
nativeHookRelay: { enabled: true },
});
await harness.waitForMethod("turn/start");
const startRequest = harness.requests.find((request) => request.method === "thread/start");
const startParams = startRequest?.params as
| { approvalPolicy?: unknown; config?: Record<string, unknown> }
| undefined;
expect(startParams?.approvalPolicy).toBe("never");
expect(Array.isArray(startParams?.config?.["hooks.PermissionRequest"])).toBe(true);
const turnStartRequest = harness.requests.find((request) => request.method === "turn/start");
expect(
(turnStartRequest?.params as { approvalPolicy?: unknown } | undefined)?.approvalPolicy,
).toBe("on-request");
const relayId = extractRelayIdFromThreadRequest(startRequest?.params);
await expect(
invokeNativeHookRelay({
provider: "codex",
relayId,
event: "permission_request",
rawPayload: {
hook_event_name: "PermissionRequest",
permission_mode: "default",
tool_name: "Bash",
tool_input: { command: "git push" },
},
}),
).resolves.toEqual({ stdout: "", stderr: "", exitCode: 0 });
expect(approvalRequester).not.toHaveBeenCalled();
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await run;
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
});
it("preserves explicit native permission request relay events in app-server approval modes", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
@@ -454,7 +531,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
"run-2",
);
await secondHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await secondHarness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await secondRun;
expect(nativeHookRelayTesting.getNativeHookRelayRegistrationForTests(firstRelayId)?.runId).toBe(
"run-2",
@@ -506,7 +583,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
expect(extractRelayIdFromThreadRequest(resumeRequest?.params)).toBe(firstRelayId);
expect(extractGenerationFromThreadRequest(resumeRequest?.params)).toBe(firstGeneration);
await secondHarness.completeTurn({ threadId: "thread-existing", turnId: "turn-1" });
await secondHarness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await secondRun;
testing.flushPendingCodexNativeHookRelayUnregistersForTests();
});
@@ -636,7 +713,7 @@ describe("runCodexAppServerAttempt native hook relay", () => {
});
const harness = createStartedThreadHarness(async (method) => {
if (method === "thread/resume") {
throw new Error("resume failed");
throw new CodexAppServerRpcError({ code: -32000, message: "resume failed" }, method);
}
return undefined;
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -37,10 +37,10 @@ import {
} from "./run-attempt-test-harness.js";
import { testing } from "./run-attempt.js";
import {
createCodexTestBindingStore,
readCodexAppServerBinding,
resolveCodexAppServerBindingPath,
writeCodexAppServerBinding as writeRawCodexAppServerBinding,
} from "./session-binding.js";
writeCodexAppServerBinding,
} from "./session-binding.test-helpers.js";
setupRunAttemptTestHooks();
@@ -71,7 +71,7 @@ describe("createCodexAttemptTurnWatchController", () => {
const onTimeout = vi.fn();
const onAbort = vi.fn();
const controller = createCodexAttemptTurnWatchController({
threadId: "thread-1",
getThreadId: () => "thread-1",
signal: new AbortController().signal,
getTurnId: () => "turn-1",
isCompleted: () => false,
@@ -100,7 +100,7 @@ describe("createCodexAttemptTurnWatchController", () => {
await new Promise((resolve) => {
setTimeout(resolve, 20);
});
controller.noteNotificationReceived("response.output_text.delta", {
controller.noteNotificationReceived("item/fileChange/patchUpdated", {
attemptProgress: true,
attemptTimeoutMs: 40,
});
@@ -113,7 +113,7 @@ describe("createCodexAttemptTurnWatchController", () => {
expect.objectContaining({
kind: "progress",
timeoutMs: 40,
lastActivityReason: "notification:response.output_text.delta",
lastActivityReason: "notification:item/fileChange/patchUpdated",
}),
);
} finally {
@@ -159,8 +159,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
path.join(tempDir, "workspace"),
);
params.timeoutMs = 200;
const bindingPath = resolveCodexAppServerBindingPath(params.sessionFile);
const run = runCodexAppServerAttempt(params, {
pluginConfig: { appServer: { turnCompletionIdleTimeoutMs: 5 } },
postToolRawAssistantCompletionIdleTimeoutMs: 5,
@@ -206,7 +204,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
),
{ interval: 1 },
);
await expect(fs.stat(bindingPath)).rejects.toMatchObject({ code: "ENOENT" });
await expect(readCodexAppServerBinding(params.sessionFile)).resolves.toBeUndefined();
expect(queueActiveRunMessageForTest("session-1", "after timeout")).toBe(false);
});
@@ -424,8 +422,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
expect(result.promptTimeoutOutcome).toBeUndefined();
});
it("unsubscribes and closes the app-server client when the active turn goes idle past the attempt timeout", async () => {
const close = vi.fn();
it("unsubscribes the app-server client when the active turn goes idle past the attempt timeout", async () => {
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-1");
@@ -443,7 +440,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
({
...mockClientRuntimeMethods(),
request,
close,
addNotificationHandler: () => () => undefined,
addRequestHandler: () => () => undefined,
}) as never,
@@ -476,7 +472,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
},
{ timeoutMs: 5_000 },
);
expect(close).toHaveBeenCalledTimes(1);
expect(queueActiveRunMessageForTest("session-1", "after timeout")).toBe(false);
});
@@ -1886,119 +1881,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
expect(completionWarnData?.timeoutMs).toBe(100);
});
it("counts native response deltas as post-tool raw assistant activity", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
let handleRequest:
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
| undefined;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-1");
}
if (method === "turn/start") {
return turnStartResult("turn-1", "inProgress");
}
return {};
});
setCodexAppServerClientFactoryForTest(
async () =>
({
...mockClientRuntimeMethods(),
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: (
handler: (request: {
id: string;
method: string;
params?: unknown;
}) => Promise<unknown>,
) => {
handleRequest = handler;
return () => undefined;
},
}) as never,
);
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.timeoutMs = 60_000;
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 500,
turnAssistantCompletionIdleTimeoutMs: 5,
postToolRawAssistantCompletionIdleTimeoutMs: 50,
turnTerminalIdleTimeoutMs: 500,
}).finally(() => {
settled = true;
});
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
const toolResult = (await handleRequest?.({
id: "request-tool-1",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "message",
arguments: { action: "send", text: "already sent" },
},
})) as { success?: boolean };
expect(toolResult.success).toBe(false);
await notify({
method: "rawResponseItem/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "message",
id: "raw-status-1",
role: "assistant",
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
},
},
});
await new Promise((resolve) => {
setTimeout(resolve, 30);
});
// This covers the future-compatible path for raw response deltas if Codex
// app-server exposes them directly; current Codex primarily emits
// rawResponseItem/completed for the raw-event surface.
await notify({
method: "response.custom_tool_call_input.delta",
params: {
item_id: "ctc-large-edit-1",
output_index: 0,
delta: '{"cmd":"apply_patch","patch":"large chunk"}',
},
});
await new Promise((resolve) => {
setTimeout(resolve, 30);
});
expect(settled).toBe(false);
await notify({
method: "turn/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
turn: { id: "turn-1", status: "completed" },
},
});
const result = await run;
expect(result.aborted).toBe(false);
expect(result.timedOut).toBe(false);
expect(result.promptError).toBeNull();
});
it("keeps the post-tool guard armed for patch update snapshots", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
let handleRequest:
@@ -2110,214 +1992,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
expect(completionWarnData?.lastNotificationMethod).toBe("item/fileChange/patchUpdated");
});
it("keeps the post-tool guard armed for scoped native response deltas", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
let handleRequest:
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
| undefined;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-1");
}
if (method === "turn/start") {
return turnStartResult("turn-1", "inProgress");
}
return {};
});
setCodexAppServerClientFactoryForTest(
async () =>
({
...mockClientRuntimeMethods(),
request,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: (
handler: (request: {
id: string;
method: string;
params?: unknown;
}) => Promise<unknown>,
) => {
handleRequest = handler;
return () => undefined;
},
}) as never,
);
const params = createParams(
path.join(tempDir, "session-scoped-delta-timeout.jsonl"),
path.join(tempDir, "workspace-scoped-delta-timeout"),
);
params.timeoutMs = 2_000;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 500,
turnAssistantCompletionIdleTimeoutMs: 5,
postToolRawAssistantCompletionIdleTimeoutMs: 50,
turnTerminalIdleTimeoutMs: 500,
});
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
await handleRequest?.({
id: "request-tool-1",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "message",
arguments: { action: "send", text: "already sent" },
},
});
await notify({
method: "rawResponseItem/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "message",
id: "raw-status-1",
role: "assistant",
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
},
},
});
await new Promise((resolve) => {
setTimeout(resolve, 30);
});
await notify({
method: "response.custom_tool_call_input.delta",
params: {
threadId: "thread-1",
turnId: "turn-1",
item_id: "ctc-large-edit-1",
output_index: 0,
delta: '{"cmd":"apply_patch","patch":"large chunk"}',
},
});
const result = await run;
expect(result.timedOut).toBe(true);
expect(result.promptError).toBe(
"codex app-server turn idle timed out waiting for turn/completed",
);
});
it("ignores unscoped native response deltas while another turn leases the client", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
let handleRequest:
| ((request: { id: string; method: string; params?: unknown }) => Promise<unknown>)
| undefined;
const warn = vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return threadStartResult("thread-1");
}
if (method === "turn/start") {
return turnStartResult("turn-1", "inProgress");
}
return {};
});
setCodexAppServerClientFactoryForTest(
async () =>
({
...mockClientRuntimeMethods(),
request,
getActiveSharedLeaseCountForUnscopedNotifications: () => 2,
addNotificationHandler: (handler: typeof notify) => {
notify = handler;
return () => undefined;
},
addRequestHandler: (
handler: (request: {
id: string;
method: string;
params?: unknown;
}) => Promise<unknown>,
) => {
handleRequest = handler;
return () => undefined;
},
}) as never,
);
const params = createParams(
path.join(tempDir, "session.jsonl"),
path.join(tempDir, "workspace"),
);
params.timeoutMs = 60_000;
let settled = false;
const run = runCodexAppServerAttempt(params, {
turnCompletionIdleTimeoutMs: 500,
turnAssistantCompletionIdleTimeoutMs: 5,
postToolRawAssistantCompletionIdleTimeoutMs: 80,
turnTerminalIdleTimeoutMs: 500,
}).finally(() => {
settled = true;
});
await vi.waitFor(() => expect(handleRequest).toBeTypeOf("function"), fastWait);
await handleRequest?.({
id: "request-tool-1",
method: "item/tool/call",
params: {
threadId: "thread-1",
turnId: "turn-1",
callId: "call-1",
namespace: null,
tool: "message",
arguments: { action: "send", text: "already sent" },
},
});
await notify({
method: "rawResponseItem/completed",
params: {
threadId: "thread-1",
turnId: "turn-1",
item: {
type: "message",
id: "raw-status-1",
role: "assistant",
content: [{ type: "output_text", text: "I'm writing a large patch now." }],
},
},
});
await new Promise((resolve) => {
setTimeout(resolve, 40);
});
await notify({
method: "response.custom_tool_call_input.delta",
params: {
item_id: "foreign-large-edit-1",
output_index: 0,
delta: '{"cmd":"apply_patch","patch":"other turn"}',
},
});
await vi.waitFor(() => expect(settled).toBe(true), fastWait);
const result = await run;
expect(result.aborted).toBe(true);
expect(result.timedOut).toBe(true);
expect(result.promptError).toBe(
"codex app-server turn idle timed out waiting for turn/completed",
);
const completionWarnCall = warn.mock.calls.find(
([message]) => message === "codex app-server turn idle timed out waiting for completion",
);
const completionWarnData = completionWarnCall?.[1] as
| {
lastActivityReason?: string;
lastNotificationMethod?: string;
}
| undefined;
expect(completionWarnData?.lastActivityReason).toBe("notification:rawResponseItem/completed");
expect(completionWarnData?.lastNotificationMethod).toBe("rawResponseItem/completed");
});
it("times out post-native-tool raw assistant progress after the post-tool timeout", async () => {
let notify: (notification: CodexServerNotification) => Promise<void> = async () => undefined;
const request = vi.fn(async (method: string) => {
@@ -3018,6 +2692,47 @@ describe("runCodexAppServerAttempt turn watches", () => {
);
});
it("retires a timed-out client even when binding cleanup fails", async () => {
vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const baseBindingStore = createCodexTestBindingStore();
const bindingStore = {
...baseBindingStore,
mutate: vi.fn(async (...args: Parameters<typeof baseBindingStore.mutate>) => {
if (args[1].kind === "clear") {
throw new Error("binding store unavailable");
}
return await baseBindingStore.mutate(...args);
}),
};
const harness = createStartedThreadHarness();
const release = vi.fn();
const abandon = vi.fn(async () => undefined);
const params = createParams(
path.join(tempDir, "session-retirement.jsonl"),
path.join(tempDir, "workspace-retirement"),
);
params.timeoutMs = 200;
const result = await runCodexAppServerAttempt(params, {
bindingStore,
turnCompletionIdleTimeoutMs: 15,
clientLeaseFactory: async () => ({ client: harness.client, release, abandon }),
});
expect(result.timedOut).toBe(true);
expect(bindingStore.mutate).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({ kind: "clear", threadId: "thread-1" }),
);
expect(harness.request).toHaveBeenCalledWith(
"thread/unsubscribe",
{ threadId: "thread-1" },
{ timeoutMs: 5_000 },
);
expect(abandon).toHaveBeenCalledOnce();
expect(release).not.toHaveBeenCalled();
});
it("clears the thread binding after a completion-idle timeout so the next turn starts fresh", async () => {
// Regression for openclaw#89974. The "user interrupted the previous turn on
// purpose" wording is Codex's generic <turn_aborted> rollout marker, written
@@ -3029,6 +2744,7 @@ describe("runCodexAppServerAttempt turn watches", () => {
vi.spyOn(embeddedAgentLog, "warn").mockImplementation(() => undefined);
const sessionFile = path.join(tempDir, "session-89974.jsonl");
const workspaceDir = path.join(tempDir, "workspace-89974");
const firstParams = createParams(sessionFile, workspaceDir);
await writeCodexAppServerBinding(sessionFile, {
threadId: "thread-existing",
cwd: workspaceDir,
@@ -3039,7 +2755,6 @@ describe("runCodexAppServerAttempt turn watches", () => {
// Turn 1: resume an existing thread, then never deliver turn/completed.
const firstHarness = createResumeHarness();
const firstParams = createParams(sessionFile, workspaceDir);
firstParams.timeoutMs = 200;
const firstRun = runCodexAppServerAttempt(firstParams, { turnCompletionIdleTimeoutMs: 15 });
await firstHarness.waitForMethod("turn/start");
@@ -3081,9 +2796,9 @@ describe("runCodexAppServerAttempt turn watches", () => {
const processing = harness.notify(notification);
await Promise.resolve();
expect(readRecentCodexRateLimits()).toBeUndefined();
expect(readRecentCodexRateLimits(harness.client)).toBeUndefined();
await processing;
expect(readRecentCodexRateLimits()).toEqual(notification.params);
expect(readRecentCodexRateLimits(harness.client)).toEqual(notification.params);
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await expect(run).resolves.toMatchObject({ aborted: false, timedOut: false });
@@ -4132,9 +3847,8 @@ describe("runCodexAppServerAttempt turn watches", () => {
);
await harness.waitForMethod("turn/start");
const completed = harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
await harness.completeTurn({ threadId: "thread-1", turnId: "turn-1" });
harness.close();
await completed;
const result = await run;
expect(result.promptError ?? undefined).toBeUndefined();

View File

@@ -1,7 +1,8 @@
// Codex tests cover run attempt.usage limits plugin behavior.
import path from "node:path";
import { saveAuthProfileStore } from "openclaw/plugin-sdk/agent-runtime";
import { describe, expect, it } from "vitest";
import { rememberCodexRateLimits } from "./rate-limit-cache.js";
import { readCodexRateLimitsRevision, rememberCodexRateLimitsRead } from "./rate-limit-cache.js";
import {
createParams,
createStartedThreadHarness,
@@ -25,7 +26,11 @@ describe("runCodexAppServerAttempt usage limits", () => {
if (!harnessRef.current) {
throw new Error("Expected Codex app-server harness to be initialized");
}
void harnessRef.current.notify(rateLimitsUpdated(resetsAt));
const revisionBeforeUpdate = readCodexRateLimitsRevision(harnessRef.current.client);
await harnessRef.current.notify(rateLimitsUpdated(resetsAt));
expect(readCodexRateLimitsRevision(harnessRef.current.client)).toBe(
revisionBeforeUpdate + 1,
);
throw Object.assign(new Error("You've reached your usage limit."), {
data: { codexErrorInfo: "usageLimitExceeded" },
});
@@ -36,6 +41,7 @@ describe("runCodexAppServerAttempt usage limits", () => {
const params = createParams(sessionFile, workspaceDir);
params.authProfileId = authProfileId;
params.agentDir = path.join(tempDir, "agents", "main", "agent");
params.authProfileStore = {
version: 1,
profiles: {
@@ -48,11 +54,13 @@ describe("runCodexAppServerAttempt usage limits", () => {
},
},
};
saveAuthProfileStore(params.authProfileStore, params.agentDir);
const result = await runCodexAppServerAttempt(params);
expect(result.promptErrorSource).toBe("prompt");
expect(result.promptError).toContain("You've reached your Codex subscription usage limit.");
expect(result.promptError).toContain("Next reset in");
expect(params.authProfileStore.usageStats?.[authProfileId]?.blockedUntil).toBe(resetsAt * 1000);
});
it("uses a recent Codex rate-limit snapshot when turn/start omits reset details", async () => {
@@ -60,7 +68,15 @@ describe("runCodexAppServerAttempt usage limits", () => {
const workspaceDir = path.join(tempDir, "workspace");
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
const authProfileId = "openai:work";
rememberCodexRateLimits({
const harness = createStartedThreadHarness(async (method) => {
if (method === "turn/start") {
throw Object.assign(new Error("You've reached your usage limit."), {
data: { codexErrorInfo: "usageLimitExceeded" },
});
}
return undefined;
});
rememberCodexRateLimitsRead(harness.client, {
rateLimits: {
limitId: "codex",
limitName: "Codex",
@@ -72,14 +88,6 @@ describe("runCodexAppServerAttempt usage limits", () => {
},
rateLimitsByLimitId: null,
});
const harness = createStartedThreadHarness(async (method) => {
if (method === "turn/start") {
throw Object.assign(new Error("You've reached your usage limit."), {
data: { codexErrorInfo: "usageLimitExceeded" },
});
}
return undefined;
});
const params = createParams(sessionFile, workspaceDir);
params.authProfileId = authProfileId;
@@ -106,6 +114,62 @@ describe("runCodexAppServerAttempt usage limits", () => {
expect(params.authProfileStore.usageStats?.[authProfileId]?.blockedUntil).toBeUndefined();
});
it("does not trust an unrelated in-turn rate-limit update for profile blocking", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const resetsAt = Math.ceil(Date.now() / 1000) + 120;
const authProfileId = "openai:work";
const harnessRef: { current?: ReturnType<typeof createStartedThreadHarness> } = {};
const harness = createStartedThreadHarness(async (method) => {
if (method === "turn/start") {
if (!harnessRef.current) {
throw new Error("Expected Codex app-server harness to be initialized");
}
await harnessRef.current.notify({
method: "account/rateLimits/updated",
params: {
rateLimits: {
limitId: "codex_other",
primary: { usedPercent: 100, windowDurationMins: 60, resetsAt: resetsAt + 60 },
rateLimitReachedType: "rate_limit_reached",
},
},
});
throw Object.assign(new Error("You've reached your usage limit."), {
data: { codexErrorInfo: "usageLimitExceeded" },
});
}
return undefined;
});
harnessRef.current = harness;
rememberCodexRateLimitsRead(harness.client, {
rateLimits: {
limitId: "codex",
primary: { usedPercent: 100, windowDurationMins: 300, resetsAt },
rateLimitReachedType: "rate_limit_reached",
},
});
const params = createParams(sessionFile, workspaceDir);
params.authProfileId = authProfileId;
params.authProfileStore = {
version: 1,
profiles: {
[authProfileId]: {
type: "oauth",
provider: "openai",
access: "access",
refresh: "refresh",
expires: Date.now() + 60_000,
},
},
};
const result = await runCodexAppServerAttempt(params);
expect(result.promptError).toContain("Next reset in");
expect(params.authProfileStore.usageStats?.[authProfileId]?.blockedUntil).toBeUndefined();
});
it("refreshes Codex account rate limits when turn/start omits reset details", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -0,0 +1,35 @@
/** Small runtime-only Codex thread config boundary shared by isolated turns. */
import type { JsonObject } from "./protocol.js";
// Stream structured patch snapshots so large generated edits keep the turn active.
const CODEX_CODE_MODE_THREAD_CONFIG: JsonObject = {
"features.code_mode": true,
"features.code_mode_only": false,
"features.apply_patch_streaming_events": true,
};
const CODEX_CODE_MODE_DISABLED_THREAD_CONFIG: JsonObject = {
"features.code_mode": false,
"features.code_mode_only": false,
};
/** Applies native code-mode policy without loading the full thread lifecycle. */
export function buildCodexRuntimeThreadConfig(
config: JsonObject | undefined,
options: { nativeCodeModeEnabled?: boolean; nativeCodeModeOnlyEnabled?: boolean } = {},
): JsonObject {
const codeModeConfig: JsonObject = {
...CODEX_CODE_MODE_THREAD_CONFIG,
"features.code_mode_only": options.nativeCodeModeOnlyEnabled === true,
};
if (options.nativeCodeModeEnabled === false) {
const disabledConfig = { ...config, ...CODEX_CODE_MODE_DISABLED_THREAD_CONFIG };
// Patch streaming belongs to native code mode; omit it when that tool surface is disabled.
delete disabledConfig["features.apply_patch_streaming_events"];
return disabledConfig;
}
if (options.nativeCodeModeOnlyEnabled === true) {
return { ...codeModeConfig, ...config, "features.code_mode_only": true };
}
return { ...codeModeConfig, ...config };
}

View File

@@ -74,6 +74,7 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
method: string;
requestParams?: unknown;
config?: OpenClawConfig;
agentId?: string;
sessionKey?: string;
sessionId?: string;
}): string | undefined {
@@ -81,6 +82,7 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
if (NODE_EXEC_BLOCKED_CONTROL_PLANE_METHODS.has(params.method)) {
const nodeExecBlock = resolveCodexNativeNodeExecBlock({
config: params.config,
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
surface: `app-server method \`${params.method}\``,
@@ -94,6 +96,7 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
}
const nodeExecBlock = resolveCodexNativeNodeExecBlock({
config: params.config,
agentId: params.agentId,
sessionKey: params.sessionKey,
sessionId: params.sessionId,
surface: `app-server method \`${params.method}\``,
@@ -107,6 +110,7 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
}
const sandboxBlock = resolveCodexNativeSandboxBlock({
config: params.config,
agentId: params.agentId,
sessionKey,
surface: `app-server method \`${params.method}\``,
});
@@ -125,6 +129,7 @@ export function resolveCodexAppServerDirectSandboxBypassBlock(params: {
/** Resolves the generic native-execution block for sandboxed or node-hosted sessions. */
export function resolveCodexNativeExecutionBlock(params: {
config?: OpenClawConfig;
agentId?: string;
sessionKey?: string;
sessionId?: string;
agentId?: string;
@@ -136,6 +141,7 @@ export function resolveCodexNativeExecutionBlock(params: {
/** Returns a block message when native Codex execution cannot honor active sandboxing. */
export function resolveCodexNativeSandboxBlock(params: {
config?: OpenClawConfig;
agentId?: string;
sessionKey?: string;
sessionId?: string;
surface: string;
@@ -146,6 +152,7 @@ export function resolveCodexNativeSandboxBlock(params: {
}
const runtime = resolveSandboxRuntimeStatus({
cfg: params.config,
agentId: params.agentId,
sessionKey,
});
if (!runtime.sandboxed) {
@@ -198,6 +205,7 @@ function formatCodexNativeSandboxBlock(params: { surface: string }): string {
function resolveCodexNativeNodeExecBlock(params: {
config?: OpenClawConfig;
agentId?: string;
sessionKey?: string;
sessionId?: string;
agentId?: string;
@@ -206,6 +214,7 @@ function resolveCodexNativeNodeExecBlock(params: {
const sessionKey = params.sessionKey?.trim() || params.sessionId?.trim();
const policy = resolveCodexNativeExecutionPolicy({
config: params.config,
agentId: params.agentId,
sessionKey,
agentId: params.agentId,
readRuntimeSessionEntry: Boolean(sessionKey),

View File

@@ -10,8 +10,31 @@ import {
} from "openclaw/plugin-sdk/agent-runtime-test-contracts";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { CodexThreadStartParams } from "./protocol.js";
import { createCodexTestModel } from "./test-support.js";
import { startOrResumeThread } from "./thread-lifecycle.js";
import {
resetCodexTestBindingStore,
registerCodexTestSessionIdentity,
testCodexAppServerBindingStore,
} from "./session-binding.test-helpers.js";
import { createCodexTestModel, ensureCodexTestClientNotificationSurface } from "./test-support.js";
import { startOrResumeThread as startOrResumeThreadImpl } from "./thread-lifecycle.js";
function startOrResumeThread(
params: Omit<Parameters<typeof startOrResumeThreadImpl>[0], "bindingStore" | "abandonClient"> & {
abandonClient?: () => Promise<void>;
},
) {
registerCodexTestSessionIdentity(
params.params.sessionFile,
params.params.sessionId,
params.params.sessionKey,
);
return startOrResumeThreadImpl({
...params,
client: ensureCodexTestClientNotificationSurface(params.client),
abandonClient: params.abandonClient ?? (async () => undefined),
bindingStore: testCodexAppServerBindingStore,
});
}
let tempDir: string;
@@ -91,6 +114,7 @@ function threadStartResult(threadId = "thread-1", serviceTier: string | null = n
describe("Codex app-server dynamic tool schema boundary contract", () => {
beforeEach(async () => {
resetCodexTestBindingStore();
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-codex-schema-contract-"));
});

View File

@@ -0,0 +1,3 @@
/** Process-stable plugin-state metadata for Codex app-server bindings. */
export const CODEX_APP_SERVER_BINDING_NAMESPACE = "app-server-thread-bindings";
export const CODEX_APP_SERVER_BINDING_MAX_ENTRIES = 50_000;

View File

@@ -0,0 +1,31 @@
/** Lazy store facade that keeps binding schema/auth code off plugin startup. */
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
CODEX_APP_SERVER_BINDING_MAX_ENTRIES,
CODEX_APP_SERVER_BINDING_NAMESPACE,
} from "./session-binding-meta.js";
import type { CodexAppServerBindingStore, StoredCodexAppServerBinding } from "./session-binding.js";
export { CODEX_APP_SERVER_BINDING_MAX_ENTRIES, CODEX_APP_SERVER_BINDING_NAMESPACE };
export type { StoredCodexAppServerBinding } from "./session-binding.js";
/** Defers schema compilation and auth loading until the first binding operation. */
export function createLazyCodexAppServerBindingStore(
state: Pick<PluginStateSyncKeyedStore<StoredCodexAppServerBinding>, "lookup" | "update">,
): CodexAppServerBindingStore {
let resolved: Promise<CodexAppServerBindingStore> | undefined;
const store = () =>
(resolved ??= import("./session-binding.js").then(({ createCodexAppServerBindingStore }) =>
createCodexAppServerBindingStore(state),
));
return {
read: async (identity) => (await store()).read(identity),
mutate: async (identity, mutation) => (await store()).mutate(identity, mutation),
prepareSessionGenerationReclaim: async (identity) =>
(await store()).prepareSessionGenerationReclaim(identity),
adoptSessionGeneration: async (identity, previousSessionId) =>
(await store()).adoptSessionGeneration(identity, previousSessionId),
retireSessionGeneration: async (identity) => (await store()).retireSessionGeneration(identity),
withLease: async (identity, run) => (await store()).withLease(identity, run),
};
}

View File

@@ -0,0 +1,116 @@
/** In-memory binding store helpers for Codex app-server tests. */
import type { PluginStateSyncKeyedStore } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
bindingStoreKey,
createCodexAppServerBindingStore,
type CodexAppServerBindingStore,
type CodexAppServerThreadBinding,
type StoredCodexAppServerBinding,
} from "./session-binding.js";
export function createCodexTestBindingStateStore(): PluginStateSyncKeyedStore<StoredCodexAppServerBinding> {
const values = new Map<string, StoredCodexAppServerBinding>();
return {
register(key, value) {
values.set(key, value);
},
registerIfAbsent(key, value) {
if (values.has(key)) {
return false;
}
values.set(key, value);
return true;
},
update(key, updateValue) {
const next = updateValue(values.get(key));
if (next === undefined) {
return false;
}
values.set(key, next);
return true;
},
lookup: (key) => values.get(key),
consume(key) {
const value = values.get(key);
values.delete(key);
return value;
},
delete: (key) => values.delete(key),
entries: () => [...values].map(([key, value]) => ({ key, value, createdAt: 0 })),
clear: () => values.clear(),
};
}
export function createCodexTestBindingStore(): CodexAppServerBindingStore {
return createCodexAppServerBindingStore(createCodexTestBindingStateStore());
}
const sharedStateStore = createCodexTestBindingStateStore();
export const testCodexAppServerBindingStore = createCodexAppServerBindingStore(sharedStateStore);
const testSessionIdentities = new Map<
string,
{ agentId: string; sessionId: string; sessionKey?: string }
>();
export function resetCodexTestBindingStore(): void {
sharedStateStore.clear();
testSessionIdentities.clear();
}
export function registerCodexTestSessionIdentity(
locator: string,
sessionId: string,
sessionKey?: string,
agentId = "main",
): void {
testSessionIdentities.set(locator, {
agentId,
sessionId,
...(sessionKey ? { sessionKey } : {}),
});
}
export function seedCodexTestBinding(locator: string, binding: CodexAppServerThreadBinding): void {
sharedStateStore.register(bindingStoreKey(testIdentity(locator)), {
version: 1,
state: "active",
binding,
});
}
function testIdentity(locator: string) {
const identity = testSessionIdentities.get(locator);
return {
kind: "session" as const,
agentId: identity?.agentId ?? "main",
sessionId: identity?.sessionId ?? locator,
...(identity?.sessionKey ? { sessionKey: identity.sessionKey } : {}),
};
}
export async function readCodexAppServerBinding(
sessionId: string,
): Promise<CodexAppServerThreadBinding | undefined> {
return await testCodexAppServerBindingStore.read(testIdentity(sessionId));
}
export async function writeCodexAppServerBinding(
sessionId: string,
binding: CodexAppServerThreadBinding,
): Promise<void> {
await testCodexAppServerBindingStore.mutate(testIdentity(sessionId), { kind: "set", binding });
}
export async function clearCodexAppServerBinding(sessionId: string): Promise<void> {
await testCodexAppServerBindingStore.mutate(testIdentity(sessionId), { kind: "clear" });
}
export async function clearCodexAppServerBindingForThread(
sessionId: string,
threadId: string,
): Promise<boolean> {
return await testCodexAppServerBindingStore.mutate(testIdentity(sessionId), {
kind: "clear",
threadId,
});
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,21 +1,29 @@
// Codex tests cover shared client plugin behavior.
import { afterEach, beforeAll, describe, expect, it, vi } from "vitest";
import { WebSocketServer, type RawData } from "ws";
import type { resolveCodexAppServerAuthProfileIdForAgent } from "./auth-bridge.js";
import { CodexAppServerClient, MIN_CODEX_APP_SERVER_VERSION } from "./client.js";
import { codexAppServerStartOptionsKey } from "./config.js";
import { createClientHarness } from "./test-support.js";
type AuthProfileResolverParams = Parameters<typeof resolveCodexAppServerAuthProfileIdForAgent>[0];
const mocks = vi.hoisted(() => ({
bridgeCodexAppServerStartOptions: vi.fn(async ({ startOptions }) => startOptions),
applyCodexAppServerAuthProfile: vi.fn(
async (_params?: { agentDir?: string; authProfileId?: string; config?: unknown }) => undefined,
async (_params?: {
client?: CodexAppServerClient;
agentDir?: string;
authProfileId?: string;
config?: unknown;
}) => undefined,
),
resolveCodexAppServerAuthProfileIdForAgent: vi.fn(
(params?: { authProfileId?: string }) => params?.authProfileId,
(params?: AuthProfileResolverParams) => params?.authProfileId,
),
resolveCodexAppServerAuthProfileStore: vi.fn(
(params?: { authProfileStore?: unknown }) => params?.authProfileStore,
),
resolveCodexAppServerAuthAccountCacheKey: vi.fn(async () => "account:credential"),
refreshCodexAppServerAuthTokens: vi.fn(async () => ({
accessToken: "refreshed-access",
chatgptAccountId: "refreshed-account",
@@ -32,8 +40,9 @@ vi.mock("./auth-bridge.js", () => ({
bridgeCodexAppServerStartOptions: mocks.bridgeCodexAppServerStartOptions,
resolveCodexAppServerAuthProfileIdForAgent: mocks.resolveCodexAppServerAuthProfileIdForAgent,
resolveCodexAppServerAuthProfileStore: mocks.resolveCodexAppServerAuthProfileStore,
refreshCodexAppServerAuthTokens: mocks.refreshCodexAppServerAuthTokens,
resolveCodexAppServerAuthAccountCacheKey: mocks.resolveCodexAppServerAuthAccountCacheKey,
resolveCodexAppServerFallbackApiKeyCacheKey: mocks.resolveCodexAppServerFallbackApiKeyCacheKey,
refreshCodexAppServerAuthTokens: mocks.refreshCodexAppServerAuthTokens,
}));
vi.mock("./managed-binary.js", () => ({
@@ -50,16 +59,10 @@ vi.mock("openclaw/plugin-sdk/agent-runtime", () => ({
}));
let listCodexAppServerModels: typeof import("./models.js").listCodexAppServerModels;
let clearSharedCodexAppServerClient: typeof import("./shared-client.js").clearSharedCodexAppServerClient;
let clearSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrent;
let clearSharedCodexAppServerClientIfCurrentAndWait: typeof import("./shared-client.js").clearSharedCodexAppServerClientIfCurrentAndWait;
let clearSharedCodexAppServerClientAndWait: typeof import("./shared-client.js").clearSharedCodexAppServerClientAndWait;
let createIsolatedCodexAppServerClient: typeof import("./shared-client.js").createIsolatedCodexAppServerClient;
let detachSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").detachSharedCodexAppServerClientIfCurrent;
let getLeasedSharedCodexAppServerClient: typeof import("./shared-client.js").getLeasedSharedCodexAppServerClient;
let getSharedCodexAppServerClient: typeof import("./shared-client.js").getSharedCodexAppServerClient;
let retainSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").retainSharedCodexAppServerClientIfCurrent;
let releaseLeasedSharedCodexAppServerClient: typeof import("./shared-client.js").releaseLeasedSharedCodexAppServerClient;
let retireSharedCodexAppServerClientIfCurrent: typeof import("./shared-client.js").retireSharedCodexAppServerClientIfCurrent;
let leaseSharedCodexAppServerClient: typeof import("./shared-client.js").leaseSharedCodexAppServerClient;
let retainSharedCodexAppServerClient: typeof import("./shared-client.js").retainSharedCodexAppServerClient;
let resetSharedCodexAppServerClientForTests: typeof import("./shared-client.js").resetSharedCodexAppServerClientForTests;
async function sendInitializeResult(
@@ -131,16 +134,10 @@ describe("shared Codex app-server client", () => {
beforeAll(async () => {
({ listCodexAppServerModels } = await import("./models.js"));
({
clearSharedCodexAppServerClient,
clearSharedCodexAppServerClientIfCurrent,
clearSharedCodexAppServerClientIfCurrentAndWait,
clearSharedCodexAppServerClientAndWait,
createIsolatedCodexAppServerClient,
detachSharedCodexAppServerClientIfCurrent,
getLeasedSharedCodexAppServerClient,
getSharedCodexAppServerClient,
retainSharedCodexAppServerClientIfCurrent,
releaseLeasedSharedCodexAppServerClient,
retireSharedCodexAppServerClientIfCurrent,
leaseSharedCodexAppServerClient,
retainSharedCodexAppServerClient,
resetSharedCodexAppServerClientForTests,
} = await import("./shared-client.js"));
});
@@ -151,17 +148,20 @@ describe("shared Codex app-server client", () => {
vi.useRealTimers();
mocks.bridgeCodexAppServerStartOptions.mockClear();
mocks.applyCodexAppServerAuthProfile.mockClear();
mocks.applyCodexAppServerAuthProfile.mockImplementation(async () => undefined);
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockClear();
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation(
(params?: { authProfileId?: string }) => params?.authProfileId,
(params?: AuthProfileResolverParams) => params?.authProfileId,
);
mocks.resolveCodexAppServerAuthProfileStore.mockClear();
mocks.resolveCodexAppServerAuthProfileStore.mockImplementation(
(params?: { authProfileStore?: unknown }) => params?.authProfileStore,
);
mocks.refreshCodexAppServerAuthTokens.mockClear();
mocks.resolveCodexAppServerAuthAccountCacheKey.mockClear();
mocks.resolveCodexAppServerAuthAccountCacheKey.mockResolvedValue("account:credential");
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockClear();
mocks.resolveCodexAppServerFallbackApiKeyCacheKey.mockReturnValue(undefined);
mocks.refreshCodexAppServerAuthTokens.mockClear();
mocks.resolveManagedCodexAppServerStartOptions.mockClear();
mocks.resolveManagedCodexAppServerStartOptions.mockImplementation(
async (startOptions) => startOptions,
@@ -213,11 +213,12 @@ describe("shared Codex app-server client", () => {
const abandonController = new AbortController();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const abandonedAcquire = getSharedCodexAppServerClient({
const abandonedAcquire = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
abandonSignal: abandonController.signal,
});
const activeAcquire = getSharedCodexAppServerClient({ timeoutMs: 1000 });
const abandonedResult = abandonedAcquire.catch((error: unknown) => error);
const activeAcquire = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
abandonController.abort();
@@ -225,9 +226,130 @@ describe("shared Codex app-server client", () => {
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(abandonedAcquire).resolves.toBe(harness.client);
await expect(activeAcquire).resolves.toBe(harness.client);
await expect(abandonedResult).resolves.toBeInstanceOf(Error);
const activeLease = await activeAcquire;
expect(activeLease.client).toBe(harness.client);
expect(harness.process.stdin.destroyed).toBe(false);
activeLease.release();
});
it("does not let one acquire timeout close startup owned by another caller", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const timedOutAcquire = leaseSharedCodexAppServerClient({ timeoutMs: 5 });
const activeAcquire = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
await expect(timedOutAcquire).rejects.toThrow("codex app-server initialize timed out");
expect(harness.process.stdin.destroyed).toBe(false);
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
const activeLease = await activeAcquire;
expect(activeLease.client).toBe(harness.client);
expect(harness.process.stdin.destroyed).toBe(false);
activeLease.release();
});
it("does not launch a client after its only acquire times out during preparation", async () => {
let finishManagedResolution!: () => void;
mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => {
await new Promise<void>((resolve) => {
finishManagedResolution = resolve;
});
return startOptions;
});
const startSpy = vi.spyOn(CodexAppServerClient, "start");
const startOptions = {
transport: "stdio" as const,
command: "codex",
commandSource: "managed" as const,
args: ["app-server", "--listen", "stdio://"],
headers: {},
};
await expect(
leaseSharedCodexAppServerClient({
startOptions,
authProfileId: "openai:work",
timeoutMs: 5,
}),
).rejects.toThrow("codex app-server preparation timed out");
finishManagedResolution();
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(startSpy).not.toHaveBeenCalled();
});
it("does not launch an isolated client after preparation times out", async () => {
let finishManagedResolution!: () => void;
mocks.resolveManagedCodexAppServerStartOptions.mockImplementationOnce(async (startOptions) => {
await new Promise<void>((resolve) => {
finishManagedResolution = resolve;
});
return startOptions;
});
const startSpy = vi.spyOn(CodexAppServerClient, "start");
await expect(createIsolatedCodexAppServerClient({ timeoutMs: 5 })).rejects.toThrow(
"codex app-server preparation timed out",
);
finishManagedResolution();
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(startSpy).not.toHaveBeenCalled();
});
it("does not create a pool entry when abort wins after preparation resolves", async () => {
const controller = new AbortController();
const originalThrowIfAborted = controller.signal.throwIfAborted.bind(controller.signal);
let checks = 0;
vi.spyOn(controller.signal, "throwIfAborted").mockImplementation(() => {
checks += 1;
if (checks === 2) {
controller.abort(new Error("aborted after preparation"));
}
originalThrowIfAborted();
});
const startSpy = vi.spyOn(CodexAppServerClient, "start");
await expect(
leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: "openai:work",
abandonSignal: controller.signal,
}),
).rejects.toThrow("aborted after preparation");
expect(startSpy).not.toHaveBeenCalled();
});
it("does not grant a lease when abort wins after initialization resolves", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const controller = new AbortController();
const originalThrowIfAborted = controller.signal.throwIfAborted.bind(controller.signal);
let checks = 0;
vi.spyOn(controller.signal, "throwIfAborted").mockImplementation(() => {
checks += 1;
if (checks === 4) {
controller.abort(new Error("aborted after initialization"));
}
originalThrowIfAborted();
});
const leasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: "openai:work",
abandonSignal: controller.signal,
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(leasePromise).rejects.toThrow("aborted after initialization");
expect(harness.process.stdin.destroyed).toBe(true);
});
it("does not wait for isolated initialize after a timeout closes the client", async () => {
@@ -240,6 +362,20 @@ describe("shared Codex app-server client", () => {
expect(harness.process.stdin.destroyed).toBe(true);
});
it("bounds isolated auth application with the same startup deadline", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
mocks.applyCodexAppServerAuthProfile.mockImplementationOnce(
async () => await new Promise<undefined>(() => {}),
);
const clientPromise = createIsolatedCodexAppServerClient({ timeoutMs: 100 });
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(clientPromise).rejects.toThrow("codex app-server initialize timed out");
expect(harness.process.stdin.destroyed).toBe(true);
});
it("passes the selected auth profile through the bridge helper", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
@@ -347,12 +483,41 @@ describe("shared Codex app-server client", () => {
});
});
it("installs physical-client handlers before initialization completes", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const leasePromise = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
await vi.waitFor(() => expect(harness.writes.length).toBeGreaterThanOrEqual(1));
harness.send({
id: "refresh-during-initialize",
method: "account/chatgptAuthTokens/refresh",
params: { reason: "expired" },
});
await vi.waitFor(() =>
expect(harness.writes.map((line) => JSON.parse(line) as unknown)).toContainEqual({
id: "refresh-during-initialize",
result: {
accessToken: "refreshed-access",
chatgptAccountId: "refreshed-account",
chatgptPlanType: null,
},
}),
);
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
const lease = await leasePromise;
expect(lease.client).toBe(harness.client);
expect(mocks.refreshCodexAppServerAuthTokens).toHaveBeenCalledTimes(1);
lease.release();
});
it("skips target auth resolution when native source auth is requested", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const config = { auth: { order: { openai: ["openai:target"] } } };
const clientPromise = getSharedCodexAppServerClient({
const leasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: null,
agentDir: "/tmp/openclaw-target-agent",
@@ -360,7 +525,8 @@ describe("shared Codex app-server client", () => {
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
await expect(clientPromise).resolves.toBe(harness.client);
const lease = await leasePromise;
expect(lease.client).toBe(harness.client);
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
const bridgeCall = bridgeStartOptionsCall();
expect(bridgeCall.agentDir).toBe("/tmp/openclaw-target-agent");
@@ -370,6 +536,7 @@ describe("shared Codex app-server client", () => {
expect(applyCall.agentDir).toBe("/tmp/openclaw-target-agent");
expect(applyCall.authProfileId).toBeNull();
expect(applyCall.config).toBe(config);
lease.release();
});
it("resolves the configured implicit auth profile before sharing a client", async () => {
@@ -388,7 +555,6 @@ describe("shared Codex app-server client", () => {
await expect(listPromise).resolves.toEqual({ models: [] });
const resolveCall = resolveAuthProfileCall();
expect(resolveCall).toStrictEqual({
authProfileId: undefined,
agentDir: "/tmp/openclaw-agent",
config,
});
@@ -400,6 +566,32 @@ describe("shared Codex app-server client", () => {
expect(applyCall?.config).toBe(config);
});
it("separates shared clients when implicit auth resolves to different profiles", async () => {
const first = createClientHarness();
const second = createClientHarness();
const firstConfig = { auth: { order: { openai: ["openai:work"] } } };
const secondConfig = { auth: { order: { openai: ["openai:personal"] } } };
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockImplementation(
({ config }: AuthProfileResolverParams = {}) => config?.auth?.order?.openai?.[0],
);
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
const firstList = listCodexAppServerModels({ timeoutMs: 1000, config: firstConfig });
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
const secondList = listCodexAppServerModels({ timeoutMs: 1000, config: secondConfig });
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
expect(startSpy).toHaveBeenCalledTimes(2);
});
it("uses the selected agent dir for shared app-server auth bridging", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
@@ -421,74 +613,6 @@ describe("shared Codex app-server client", () => {
expect(applyCall?.authProfileId).toBe("openai:work");
});
it("migrates legacy singleton global state into the keyed registry", async () => {
const legacy = createClientHarness();
const next = createClientHarness();
const startOptions = {
transport: "websocket" as const,
command: "codex",
args: [],
url: "ws://127.0.0.1:39175",
authToken: "tok-legacy",
headers: {},
};
const key = codexAppServerStartOptionsKey(startOptions, {
agentDir: "/tmp/openclaw-agent",
});
const globalState = globalThis as typeof globalThis & {
[key: symbol]: unknown;
};
globalState[Symbol.for("openclaw.codexAppServerClientState")] = {
key,
client: legacy.client,
promise: Promise.resolve(legacy.client),
};
await expect(getSharedCodexAppServerClient({ startOptions })).resolves.toBe(legacy.client);
legacy.client.close();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(next.client);
const list = listCodexAppServerModels({ timeoutMs: 1000, startOptions });
await sendInitializeResult(next, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(next);
await expect(list).resolves.toEqual({ models: [] });
expect(startSpy).toHaveBeenCalledTimes(1);
});
it("preserves keyed shared-client state when adding lease metadata", async () => {
const legacy = createClientHarness();
const startOptions = {
transport: "websocket" as const,
command: "codex",
args: [],
url: "ws://127.0.0.1:39176",
authToken: "tok-keyed",
headers: {},
};
const key = codexAppServerStartOptionsKey(startOptions, {
agentDir: "/tmp/openclaw-agent",
});
const globalState = globalThis as typeof globalThis & {
[key: symbol]: unknown;
};
globalState[Symbol.for("openclaw.codexAppServerClientState")] = {
clients: new Map([[key, { client: legacy.client, promise: Promise.resolve(legacy.client) }]]),
};
await expect(getLeasedSharedCodexAppServerClient({ startOptions })).resolves.toBe(
legacy.client,
);
expect(retireSharedCodexAppServerClientIfCurrent(legacy.client)).toEqual({
activeLeases: 1,
closed: false,
});
expect(legacy.process.stdin.destroyed).toBe(false);
expect(releaseLeasedSharedCodexAppServerClient(legacy.client)).toBe(true);
expect(legacy.process.stdin.destroyed).toBe(true);
});
it("keeps an active shared client alive when another agent dir uses a different key", async () => {
const first = createClientHarness();
const second = createClientHarness();
@@ -499,6 +623,7 @@ describe("shared Codex app-server client", () => {
const firstList = listCodexAppServerModels({
timeoutMs: 1000,
authProfileId: null,
agentDir: "/tmp/openclaw-agent-one",
});
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
@@ -507,6 +632,7 @@ describe("shared Codex app-server client", () => {
const secondList = listCodexAppServerModels({
timeoutMs: 1000,
authProfileId: null,
agentDir: "/tmp/openclaw-agent-two",
});
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
@@ -518,6 +644,76 @@ describe("shared Codex app-server client", () => {
expect(second.process.stdin.destroyed).toBe(false);
});
it("bounds idle shared clients and closes the least recently released process", async () => {
const harnesses = Array.from({ length: 5 }, () => createClientHarness());
const startSpy = vi.spyOn(CodexAppServerClient, "start");
for (const harness of harnesses) {
startSpy.mockReturnValueOnce(harness.client);
}
for (const [index, harness] of harnesses.slice(0, 4).entries()) {
const leasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: null,
agentDir: `/tmp/openclaw-agent-${index}`,
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
const lease = await leasePromise;
lease.release();
}
const refreshed = await leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: null,
agentDir: "/tmp/openclaw-agent-0",
});
refreshed.release();
const newestLeasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: null,
agentDir: "/tmp/openclaw-agent-4",
});
await sendInitializeResult(harnesses[4], "openclaw/0.125.0 (macOS; test)");
(await newestLeasePromise).release();
expect(harnesses[0]?.process.stdin.destroyed).toBe(false);
expect(harnesses[1]?.process.stdin.destroyed).toBe(true);
for (const harness of harnesses.slice(2)) {
expect(harness.process.stdin.destroyed).toBe(false);
}
});
it("does not evict a client retained by detached background work", async () => {
const retained = createClientHarness();
const idle = Array.from({ length: 5 }, () => createClientHarness());
const startSpy = vi.spyOn(CodexAppServerClient, "start");
for (const harness of [retained, ...idle]) {
startSpy.mockReturnValueOnce(harness.client);
}
const retainedLeasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: null,
agentDir: "/tmp/openclaw-retained-agent",
});
await sendInitializeResult(retained, "openclaw/0.125.0 (macOS; test)");
const retainedLease = await retainedLeasePromise;
const releaseRetention = retainSharedCodexAppServerClient(retained.client);
retainedLease.release();
for (const [index, harness] of idle.entries()) {
const leasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: null,
agentDir: `/tmp/openclaw-idle-agent-${index}`,
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
(await leasePromise).release();
}
expect(retained.process.stdin.destroyed).toBe(false);
releaseRetention();
expect(retained.process.stdin.destroyed).toBe(false);
});
it("resolves the managed binary before bridging and spawning the shared client", async () => {
const harness = createClientHarness();
const startSpy = vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
@@ -543,6 +739,28 @@ describe("shared Codex app-server client", () => {
expect(startCall?.commandSource).toBe("resolved-managed");
});
it("resolves managed binary metadata once while refreshing credentials per acquire", async () => {
const harness = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValue(harness.client);
const firstLeasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: "openai:work",
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
const firstLease = await firstLeasePromise;
firstLease.release();
const secondLease = await leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: "openai:work",
});
secondLease.release();
expect(mocks.resolveManagedCodexAppServerStartOptions).toHaveBeenCalledOnce();
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledTimes(2);
});
it("starts an independent shared client when the bridged auth token changes", async () => {
const first = createClientHarness();
const second = createClientHarness();
@@ -585,7 +803,29 @@ describe("shared Codex app-server client", () => {
expect(first.process.stdin.destroyed).toBe(false);
});
it("starts an independent shared client when fallback api-key auth changes", async () => {
it("keeps native and fallback auth in separate shared scopes", async () => {
const first = createClientHarness();
const second = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
const firstList = listCodexAppServerModels({ timeoutMs: 1000, authProfileId: null });
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
expect(startSpy).toHaveBeenCalledTimes(2);
expect(first.process.stdin.destroyed).toBe(false);
expect(second.process.stdin.destroyed).toBe(false);
});
it("starts a new shared client when fallback api-key auth changes", async () => {
const first = createClientHarness();
const second = createClientHarness();
const startSpy = vi
@@ -611,6 +851,37 @@ describe("shared Codex app-server client", () => {
expect(second.process.stdin.destroyed).toBe(false);
});
it("starts a new shared client when an explicit profile credential changes", async () => {
const first = createClientHarness();
const second = createClientHarness();
const startSpy = vi
.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
mocks.resolveCodexAppServerAuthAccountCacheKey
.mockResolvedValueOnce("account:credential-1")
.mockResolvedValueOnce("account:credential-2");
const firstList = listCodexAppServerModels({
timeoutMs: 1000,
authProfileId: "openai:work",
});
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
const secondList = listCodexAppServerModels({
timeoutMs: 1000,
authProfileId: "openai:work",
});
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
expect(startSpy).toHaveBeenCalledTimes(2);
expect(first.process.stdin.destroyed).toBe(false);
});
it("does not let one shared-client failure tear down another keyed client", async () => {
const first = createClientHarness();
const second = createClientHarness();
@@ -655,128 +926,154 @@ describe("shared Codex app-server client", () => {
expect(second.process.kill).not.toHaveBeenCalled();
});
it("only clears the shared client that is still current", async () => {
it("abandons a matching shared client without disturbing its replacement", async () => {
const first = createClientHarness();
const second = createClientHarness();
vi.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
const firstCloseAndWait = vi.spyOn(first.client, "closeAndWait");
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
const firstLeasePromise = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
const firstLease = await firstLeasePromise;
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
await firstLease.abandon();
expect(first.process.stdin.destroyed).toBe(true);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
const secondLeasePromise = leaseSharedCodexAppServerClient({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
const secondLease = await secondLeasePromise;
expect(clearSharedCodexAppServerClientIfCurrent(first.client)).toBe(false);
await firstLease.abandon();
firstLease.release();
expect(firstCloseAndWait).toHaveBeenCalledTimes(1);
expect(second.process.kill).not.toHaveBeenCalled();
expect(clearSharedCodexAppServerClientIfCurrent(second.client)).toBe(true);
await secondLease.abandon();
expect(second.process.stdin.destroyed).toBe(true);
});
it("can detach the current shared client without closing it", async () => {
const first = createClientHarness();
const second = createClientHarness();
vi.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
it("closes an abandoned client without removing its idle replacement", async () => {
const retired = createClientHarness();
const replacement = createClientHarness();
const otherIdle = Array.from({ length: 4 }, () => createClientHarness());
const startSpy = vi.spyOn(CodexAppServerClient, "start");
for (const harness of [retired, replacement, ...otherIdle]) {
startSpy.mockReturnValueOnce(harness.client);
}
const sharedOptions = {
timeoutMs: 1000,
authProfileId: "openai:work",
};
const retiringLeasePromise = leaseSharedCodexAppServerClient(sharedOptions);
const liveLeasePromise = leaseSharedCodexAppServerClient(sharedOptions);
await sendInitializeResult(retired, "openclaw/0.125.0 (macOS; test)");
const retiringLease = await retiringLeasePromise;
const liveLease = await liveLeasePromise;
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
await retiringLease.abandon();
const replacementLeasePromise = leaseSharedCodexAppServerClient(sharedOptions);
await sendInitializeResult(replacement, "openclaw/0.125.0 (macOS; test)");
const replacementLease = await replacementLeasePromise;
replacementLease.release();
expect(detachSharedCodexAppServerClientIfCurrent(first.client)).toBe(true);
expect(first.process.stdin.destroyed).toBe(false);
const releaseRetention = retainSharedCodexAppServerClient(retired.client);
for (const [index, harness] of otherIdle.entries()) {
const leasePromise = leaseSharedCodexAppServerClient({
...sharedOptions,
agentDir: `/tmp/openclaw-retired-idle-agent-${index}`,
});
await sendInitializeResult(harness, "openclaw/0.125.0 (macOS; test)");
(await leasePromise).release();
}
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
expect(detachSharedCodexAppServerClientIfCurrent(first.client)).toBe(false);
first.client.close();
expect(first.process.stdin.destroyed).toBe(true);
expect(second.process.kill).not.toHaveBeenCalled();
expect(detachSharedCodexAppServerClientIfCurrent(second.client)).toBe(true);
second.client.close();
expect(second.process.stdin.destroyed).toBe(true);
expect(replacement.process.stdin.destroyed).toBe(true);
expect(retired.process.stdin.destroyed).toBe(true);
releaseRetention();
liveLease.release();
expect(retired.process.stdin.destroyed).toBe(true);
});
it("closes a retired shared app-server after all active leases release", async () => {
const first = createClientHarness();
const second = createClientHarness();
vi.spyOn(CodexAppServerClient, "start")
.mockReturnValueOnce(first.client)
.mockReturnValueOnce(second.client);
const firstList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
const releaseFirst = retainSharedCodexAppServerClientIfCurrent(first.client);
const releaseSecond = retainSharedCodexAppServerClientIfCurrent(first.client);
expect(releaseFirst).toBeTypeOf("function");
expect(releaseSecond).toBeTypeOf("function");
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
activeLeases: 2,
closed: false,
});
expect(first.process.stdin.destroyed).toBe(false);
const secondList = listCodexAppServerModels({ timeoutMs: 1000 });
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
releaseFirst?.();
expect(first.process.stdin.destroyed).toBe(false);
releaseSecond?.();
expect(first.process.stdin.destroyed).toBe(true);
expect(second.process.kill).not.toHaveBeenCalled();
expect(retireSharedCodexAppServerClientIfCurrent(second.client)).toEqual({
activeLeases: 0,
closed: true,
});
expect(second.process.stdin.destroyed).toBe(true);
});
it("leases shared app-server clients before returning concurrent acquirers", async () => {
it("settles each concurrent shared-client lease exactly once", async () => {
const first = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
const close = vi.spyOn(first.client, "close");
const closeAndWait = vi.spyOn(first.client, "closeAndWait");
const firstLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
const secondLease = getLeasedSharedCodexAppServerClient({ timeoutMs: 1000 });
const firstLeasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: "openai:work",
});
const secondLeasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: "openai:work",
});
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await expect(firstLease).resolves.toBe(first.client);
await expect(secondLease).resolves.toBe(first.client);
const firstLease = await firstLeasePromise;
const secondLease = await secondLeasePromise;
expect(firstLease.client).toBe(first.client);
expect(secondLease.client).toBe(first.client);
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
activeLeases: 2,
closed: false,
});
expect(retireSharedCodexAppServerClientIfCurrent(first.client)).toEqual({
activeLeases: 2,
closed: false,
});
expect(first.process.stdin.destroyed).toBe(false);
expect(mocks.resolveManagedCodexAppServerStartOptions).toHaveBeenCalledTimes(1);
expect(mocks.resolveCodexAppServerAuthProfileIdForAgent).not.toHaveBeenCalled();
expect(mocks.bridgeCodexAppServerStartOptions).toHaveBeenCalledTimes(1);
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
expect(first.process.stdin.destroyed).toBe(false);
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(true);
await firstLease.abandon();
await firstLease.abandon();
firstLease.release();
expect(first.process.stdin.destroyed).toBe(true);
expect(releaseLeasedSharedCodexAppServerClient(first.client)).toBe(false);
expect(close).not.toHaveBeenCalled();
expect(closeAndWait).toHaveBeenCalledTimes(1);
await expect(firstLease.client.request("model/list", {})).rejects.toThrow();
secondLease.release();
secondLease.release();
await secondLease.abandon();
expect(first.process.stdin.destroyed).toBe(true);
expect(close).not.toHaveBeenCalled();
expect(closeAndWait).toHaveBeenCalledTimes(1);
});
it("waits only for the shared client that is still current", async () => {
it("waits for an already-detached client retirement during clear-all shutdown", async () => {
const first = createClientHarness();
vi.spyOn(CodexAppServerClient, "start").mockReturnValueOnce(first.client);
let finishRetirement!: () => void;
const retirementBlocked = new Promise<void>((resolve) => {
finishRetirement = resolve;
});
const closeAndWait = vi.spyOn(first.client, "closeAndWait").mockImplementation(async () => {
first.client.close();
await retirementBlocked;
});
const leasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
authProfileId: "openai:work",
});
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
const lease = await leasePromise;
const abandon = lease.abandon();
await vi.waitFor(() => expect(closeAndWait).toHaveBeenCalledOnce());
let shutdownSettled = false;
const shutdown = clearSharedCodexAppServerClientAndWait({
exitTimeoutMs: 25,
forceKillDelayMs: 5,
}).then(() => {
shutdownSettled = true;
});
await new Promise<void>((resolve) => {
setImmediate(resolve);
});
expect(shutdownSettled).toBe(false);
finishRetirement();
await Promise.all([abandon, shutdown]);
expect(closeAndWait).toHaveBeenCalledTimes(1);
expect(first.process.stdin.destroyed).toBe(true);
});
it("abandons only the client owned by the exact lease", async () => {
const first = createClientHarness();
const second = createClientHarness();
vi.spyOn(CodexAppServerClient, "start")
@@ -785,33 +1082,28 @@ describe("shared Codex app-server client", () => {
const firstCloseAndWait = vi.spyOn(first.client, "closeAndWait");
const secondCloseAndWait = vi.spyOn(second.client, "closeAndWait");
const firstList = listCodexAppServerModels({
const firstLeasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
agentDir: "/tmp/openclaw-agent-one",
});
await sendInitializeResult(first, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(first);
await expect(firstList).resolves.toEqual({ models: [] });
const firstLease = await firstLeasePromise;
const secondList = listCodexAppServerModels({
const secondLeasePromise = leaseSharedCodexAppServerClient({
timeoutMs: 1000,
agentDir: "/tmp/openclaw-agent-two",
});
await sendInitializeResult(second, "openclaw/0.125.0 (macOS; test)");
await sendEmptyModelList(second);
await expect(secondList).resolves.toEqual({ models: [] });
const secondLease = await secondLeasePromise;
await expect(
clearSharedCodexAppServerClientIfCurrentAndWait(first.client, {
exitTimeoutMs: 25,
forceKillDelayMs: 5,
}),
).resolves.toBe(true);
await firstLease.abandon();
expect(firstCloseAndWait).toHaveBeenCalledTimes(1);
expect(secondCloseAndWait).not.toHaveBeenCalled();
expect(first.process.stdin.destroyed).toBe(true);
expect(second.process.stdin.destroyed).toBe(false);
await secondLease.abandon();
});
it("uses a fresh websocket Authorization header after shared-client token rotation", async () => {
@@ -872,7 +1164,7 @@ describe("shared Codex app-server client", () => {
expect(authHeaders).toEqual(["Bearer tok-first", "Bearer tok-second"]);
} finally {
clearSharedCodexAppServerClient();
resetSharedCodexAppServerClientForTests();
await new Promise<void>((resolve, reject) => {
server.close((error) => (error ? reject(error) : resolve()));
});

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