mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-22 06:52:07 +08:00
Compare commits
517 Commits
feeds-appr
...
codex/refa
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7641ec8ba7 | ||
|
|
28cbeaaa80 | ||
|
|
e773981088 | ||
|
|
9d4d07175d | ||
|
|
cdb7e64994 | ||
|
|
7daba184b5 | ||
|
|
3d1935dcc1 | ||
|
|
b832153d0a | ||
|
|
9bd2b4b34b | ||
|
|
02abd3adb5 | ||
|
|
0dda56e0f6 | ||
|
|
365d78605d | ||
|
|
a2c64b08ff | ||
|
|
90decc657d | ||
|
|
e670de672d | ||
|
|
c4aba64b58 | ||
|
|
3a024f6a8d | ||
|
|
c866b087eb | ||
|
|
620cca1d7f | ||
|
|
353012b8a8 | ||
|
|
a37c6c935a | ||
|
|
97b97a9999 | ||
|
|
cbbb466852 | ||
|
|
c2de9d0822 | ||
|
|
e46aaead2c | ||
|
|
c191d7978b | ||
|
|
1b5e1e2d53 | ||
|
|
ab41a311cf | ||
|
|
9e70d251b0 | ||
|
|
025f8fb087 | ||
|
|
e012f2cd3c | ||
|
|
73c988a9c8 | ||
|
|
8b4eedf1bc | ||
|
|
b43eedbb18 | ||
|
|
ebbf506a77 | ||
|
|
e0c1add79a | ||
|
|
eae1e8f3f8 | ||
|
|
ba34052a0e | ||
|
|
ce78ac3efb | ||
|
|
6c2c43d63f | ||
|
|
0168ff0c2e | ||
|
|
22bdda2555 | ||
|
|
2755112353 | ||
|
|
57c7fa22bb | ||
|
|
269e44e164 | ||
|
|
2e75d925ad | ||
|
|
0e763c1499 | ||
|
|
6ed9fb8ec2 | ||
|
|
a826d6a4a4 | ||
|
|
9d519c1481 | ||
|
|
b09b35c13c | ||
|
|
15f2a56590 | ||
|
|
e66c36df37 | ||
|
|
42c504b8b1 | ||
|
|
9ac3759ffc | ||
|
|
d7f747af3b | ||
|
|
9cb3b4ea2b | ||
|
|
f257116c92 | ||
|
|
a88ce96ee1 | ||
|
|
448b3fa0be | ||
|
|
88ad407be2 | ||
|
|
13e77cc055 | ||
|
|
b78718f42a | ||
|
|
54dddda68d | ||
|
|
6084442ab6 | ||
|
|
692c7e78f4 | ||
|
|
3f166b1f64 | ||
|
|
8e8905560b | ||
|
|
3968fea383 | ||
|
|
43e8c29fbf | ||
|
|
7e80bb8abf | ||
|
|
9826619e22 | ||
|
|
8f3672beaa | ||
|
|
0d25928fa4 | ||
|
|
807641548b | ||
|
|
f87f30d429 | ||
|
|
22c5ced69f | ||
|
|
faa4f5a23b | ||
|
|
fc0b28cb73 | ||
|
|
8265acaacb | ||
|
|
d20e96a650 | ||
|
|
8ff1d3e67b | ||
|
|
49a9032705 | ||
|
|
aab1dd88e0 | ||
|
|
a81a505c72 | ||
|
|
7d219bd6e7 | ||
|
|
ece7d0945c | ||
|
|
979238dbb3 | ||
|
|
ab165d119c | ||
|
|
6f3df79f17 | ||
|
|
7c2fb845db | ||
|
|
00f67b845b | ||
|
|
6ef9207201 | ||
|
|
3283540c78 | ||
|
|
03b022b88e | ||
|
|
45f9358877 | ||
|
|
29ec5b331c | ||
|
|
fa08942396 | ||
|
|
2c9192a9a8 | ||
|
|
955f3ed094 | ||
|
|
7217477553 | ||
|
|
97b0b559ad | ||
|
|
1b299f4dbf | ||
|
|
7064d198a3 | ||
|
|
f18ff7551e | ||
|
|
58d295840e | ||
|
|
896b3c612d | ||
|
|
2f240a4a4c | ||
|
|
172412d756 | ||
|
|
58578b3250 | ||
|
|
ce9769faae | ||
|
|
e8920f6f6b | ||
|
|
9be53b4aa2 | ||
|
|
3ec2a46907 | ||
|
|
15a2d74320 | ||
|
|
77f07a11e7 | ||
|
|
7a0d36f3d0 | ||
|
|
0a707afb9a | ||
|
|
bdeda6553b | ||
|
|
3499b277e3 | ||
|
|
8c8857c3ef | ||
|
|
d75613e794 | ||
|
|
beb8897f49 | ||
|
|
add5f76a1e | ||
|
|
9a9f4dbefe | ||
|
|
5beaaf343c | ||
|
|
1db811282c | ||
|
|
aa23d9f34e | ||
|
|
2962c95010 | ||
|
|
80d3b132a5 | ||
|
|
1a5d84d3fe | ||
|
|
71a75b9b28 | ||
|
|
b1f562570a | ||
|
|
bdcc691745 | ||
|
|
4461e257e3 | ||
|
|
76014cfe95 | ||
|
|
498ff1fb5a | ||
|
|
ae81aa018d | ||
|
|
1706bfda2c | ||
|
|
a1201e99fc | ||
|
|
90d2f161c9 | ||
|
|
bff7134a69 | ||
|
|
e59d0b540e | ||
|
|
aa5fcf70f7 | ||
|
|
63ac2e2ce0 | ||
|
|
803064c6e0 | ||
|
|
577e5a4692 | ||
|
|
a49f3f9362 | ||
|
|
7b9ddbda99 | ||
|
|
0f83051353 | ||
|
|
4341cf24cc | ||
|
|
6a3f990140 | ||
|
|
81abc2b21b | ||
|
|
09fcafffbc | ||
|
|
2a93d7b9c5 | ||
|
|
0eaefc9050 | ||
|
|
52e01676be | ||
|
|
df68b81006 | ||
|
|
a5417b5c6c | ||
|
|
da2c7e2d2b | ||
|
|
3a14f247ad | ||
|
|
5c36001fcb | ||
|
|
05bed72a8d | ||
|
|
c2433d41a7 | ||
|
|
d368fd620c | ||
|
|
7dc7deaa13 | ||
|
|
a2ff59fdb2 | ||
|
|
b12223a79f | ||
|
|
f519ceab9c | ||
|
|
1f1b1aee6b | ||
|
|
62b2e9ef14 | ||
|
|
0f67474251 | ||
|
|
e56fd1dc04 | ||
|
|
b3968f69c9 | ||
|
|
b0df6dc10e | ||
|
|
141fb2b119 | ||
|
|
64b6488f6c | ||
|
|
e1fc4683bb | ||
|
|
85ab952956 | ||
|
|
abd5fb4494 | ||
|
|
aea050b43e | ||
|
|
85f552bf37 | ||
|
|
dafd98dd98 | ||
|
|
3632c62f85 | ||
|
|
ad5d2cbc1b | ||
|
|
7cda58c109 | ||
|
|
5c0b99ae2b | ||
|
|
979925c194 | ||
|
|
2f9f45f734 | ||
|
|
32cbaecd09 | ||
|
|
1989726eb6 | ||
|
|
2454acc287 | ||
|
|
fce5db415b | ||
|
|
2166652eb3 | ||
|
|
7a9c269541 | ||
|
|
aa893b9228 | ||
|
|
98a7741468 | ||
|
|
3df4341e5a | ||
|
|
ecac665bf3 | ||
|
|
021fd5de2b | ||
|
|
60159b9f00 | ||
|
|
165440117e | ||
|
|
fddfcbe10e | ||
|
|
7c850bdf38 | ||
|
|
2bc20f2ec5 | ||
|
|
ed500dda25 | ||
|
|
bc754b3160 | ||
|
|
b972956173 | ||
|
|
29444b26f2 | ||
|
|
7fc5a72433 | ||
|
|
a590f7f690 | ||
|
|
2252674168 | ||
|
|
60612ff492 | ||
|
|
c5623e72f3 | ||
|
|
947c21ee5a | ||
|
|
99f58ae6d6 | ||
|
|
3f0e740f83 | ||
|
|
106961b513 | ||
|
|
d0001f96f0 | ||
|
|
527bd807b9 | ||
|
|
7546231762 | ||
|
|
a977dc843d | ||
|
|
6ad7f66af2 | ||
|
|
1b4fb6291d | ||
|
|
ee69465fe9 | ||
|
|
7b329ade32 | ||
|
|
44422b2151 | ||
|
|
48b338a5a9 | ||
|
|
d4f68475fd | ||
|
|
d81ae7a441 | ||
|
|
99d8549de6 | ||
|
|
7a077ffead | ||
|
|
b980d678a4 | ||
|
|
e02e3d6971 | ||
|
|
6fa05685ea | ||
|
|
6585cb3b44 | ||
|
|
730c7269ef | ||
|
|
d72f7edf2d | ||
|
|
24b6e6ba96 | ||
|
|
c33f8c20ef | ||
|
|
1c0c072bc2 | ||
|
|
aaf335af04 | ||
|
|
ad049ef083 | ||
|
|
6dc121eb6a | ||
|
|
0742a2f37a | ||
|
|
e2c567538d | ||
|
|
5c8fa5da5c | ||
|
|
9953b85e6d | ||
|
|
048014d1ab | ||
|
|
0cd6975352 | ||
|
|
5384b91866 | ||
|
|
19ec9d8979 | ||
|
|
e65619dd0c | ||
|
|
2f0f085826 | ||
|
|
0cd8db97f9 | ||
|
|
087d999fce | ||
|
|
4514b5a387 | ||
|
|
6b82d4ecb7 | ||
|
|
f719f0cf77 | ||
|
|
8ee638236a | ||
|
|
36934fd9f5 | ||
|
|
84895e9276 | ||
|
|
a6e41a0cc1 | ||
|
|
1ede829fbf | ||
|
|
b93b07ee1b | ||
|
|
405e5072fd | ||
|
|
b79dfc739c | ||
|
|
ff4808f94d | ||
|
|
602bc0baa9 | ||
|
|
a1d278b174 | ||
|
|
0fd5dae36f | ||
|
|
984e058624 | ||
|
|
a6e4afe0fa | ||
|
|
66c62d52ad | ||
|
|
9e3ef487eb | ||
|
|
739636fc33 | ||
|
|
ccc1415f6d | ||
|
|
b1608b4a4e | ||
|
|
703dfbf453 | ||
|
|
7cd58cca2a | ||
|
|
2d603c90dc | ||
|
|
4296ecb78c | ||
|
|
fe1d981a47 | ||
|
|
5cf8ba973d | ||
|
|
cb394309fe | ||
|
|
dd29a6de52 | ||
|
|
93a0b5d353 | ||
|
|
4f8fd48ea7 | ||
|
|
7679872ddf | ||
|
|
cd7385c5c6 | ||
|
|
88cf142c98 | ||
|
|
1988e1a0c5 | ||
|
|
138ffa2992 | ||
|
|
6069a030c4 | ||
|
|
0767118c26 | ||
|
|
120d08c730 | ||
|
|
6ee7714306 | ||
|
|
84c96ddb14 | ||
|
|
8b8d791472 | ||
|
|
8246b49cc5 | ||
|
|
3d5aefb50c | ||
|
|
628314f53c | ||
|
|
5c19699cb2 | ||
|
|
75fd2464cc | ||
|
|
bd5a5a0cfc | ||
|
|
9111d8ed85 | ||
|
|
1c3da22bcd | ||
|
|
7de3e0e0bb | ||
|
|
29df94382e | ||
|
|
629f78b77b | ||
|
|
68790eb4b9 | ||
|
|
9a92c3d24a | ||
|
|
3fa12177dd | ||
|
|
8855b21f99 | ||
|
|
a4e2113e1b | ||
|
|
f26f45c050 | ||
|
|
a127183094 | ||
|
|
22f696d010 | ||
|
|
3a1e49dbaa | ||
|
|
0e14a3f09b | ||
|
|
244857adbf | ||
|
|
1dbf4dbd40 | ||
|
|
fbea5b023a | ||
|
|
4e7a717868 | ||
|
|
00c2dc66b1 | ||
|
|
df7e4788ed | ||
|
|
5b93f829e5 | ||
|
|
6f1cc2f8df | ||
|
|
d9ee08a76e | ||
|
|
da35f8b4d1 | ||
|
|
bb7150de94 | ||
|
|
3d05e973f0 | ||
|
|
fb022a2b07 | ||
|
|
9afc333bd7 | ||
|
|
7b44157bc6 | ||
|
|
075965e32f | ||
|
|
73d393f812 | ||
|
|
1cbcc3e1f0 | ||
|
|
ee709f3b0f | ||
|
|
eb5bb7f6a0 | ||
|
|
592373f0ea | ||
|
|
a63230008c | ||
|
|
d0812126c8 | ||
|
|
352141a1be | ||
|
|
904c035d1c | ||
|
|
a085db6b64 | ||
|
|
819b1a3e3e | ||
|
|
e19ad8c0fd | ||
|
|
f8675b3b70 | ||
|
|
3285a10c7f | ||
|
|
e451a4e875 | ||
|
|
8e375242be | ||
|
|
93cbd16c88 | ||
|
|
7779bc64d2 | ||
|
|
5e7bb9cf9b | ||
|
|
2248aa4315 | ||
|
|
6bfe7a2b06 | ||
|
|
2babcf026e | ||
|
|
9192ff8416 | ||
|
|
118f3f3312 | ||
|
|
7d658dfd97 | ||
|
|
84a36057e9 | ||
|
|
b44e39b82c | ||
|
|
e89c255a01 | ||
|
|
a635e97965 | ||
|
|
4f278ef71c | ||
|
|
1df2cc5f02 | ||
|
|
1cda1fc9a0 | ||
|
|
af9b026241 | ||
|
|
6a23a72d74 | ||
|
|
14d362039e | ||
|
|
9391dac56d | ||
|
|
61ee4ffdfc | ||
|
|
78d1b4a9b3 | ||
|
|
0d6e0a2263 | ||
|
|
33a4845555 | ||
|
|
4a75171190 | ||
|
|
c946df0239 | ||
|
|
9ce68d0920 | ||
|
|
2c65b9b407 | ||
|
|
78a2a31a6b | ||
|
|
c719ff3183 | ||
|
|
0479da9bfb | ||
|
|
13e76544e5 | ||
|
|
c81391e270 | ||
|
|
69216f1745 | ||
|
|
a824df2e35 | ||
|
|
f60aec6e9d | ||
|
|
6293e6e3ca | ||
|
|
f4baeab47f | ||
|
|
8f06e65f33 | ||
|
|
3518fa575a | ||
|
|
a5e33b3a6b | ||
|
|
86d1e397f4 | ||
|
|
d6c7e95c7b | ||
|
|
445317a38b | ||
|
|
2844ec2bb0 | ||
|
|
459edec9ba | ||
|
|
e27c9a9a41 | ||
|
|
c80f4c110e | ||
|
|
cfc699d3f6 | ||
|
|
f04c3d6575 | ||
|
|
da03996ab7 | ||
|
|
5fd947c661 | ||
|
|
622955b3fc | ||
|
|
cd69760628 | ||
|
|
3e41587992 | ||
|
|
214a28affd | ||
|
|
9f6d5e4750 | ||
|
|
033455b6f1 | ||
|
|
8b5b150e02 | ||
|
|
4db7d6a90a | ||
|
|
d76c1daa52 | ||
|
|
9491e9187d | ||
|
|
e0ec42e0e0 | ||
|
|
a971641a54 | ||
|
|
50b5238b38 | ||
|
|
0cf941344c | ||
|
|
e6823c3d16 | ||
|
|
4b2b70ec79 | ||
|
|
b6d91d96ef | ||
|
|
dadec4500f | ||
|
|
f76a3a3bbe | ||
|
|
c2e26db61b | ||
|
|
41691a82d5 | ||
|
|
49b0487e5b | ||
|
|
4575734f59 | ||
|
|
7e7dc7505b | ||
|
|
7dca9210c9 | ||
|
|
208bed06e1 | ||
|
|
87358d7a7c | ||
|
|
e02bee6aab | ||
|
|
56c0405018 | ||
|
|
b6d754e3cb | ||
|
|
6e732b3063 | ||
|
|
423b1b3a42 | ||
|
|
faeb731a29 | ||
|
|
d6075c1694 | ||
|
|
a67f809b33 | ||
|
|
1f1c434ede | ||
|
|
3c3f1010aa | ||
|
|
0e980be284 | ||
|
|
27450f6b42 | ||
|
|
d491e9c69b | ||
|
|
6fc0a3a9bd | ||
|
|
0a1ce14dd1 | ||
|
|
f9f94e7dcd | ||
|
|
1e105d5340 | ||
|
|
21c966616f | ||
|
|
be7807f65e | ||
|
|
7ee1dafd4f | ||
|
|
3a7a385baf | ||
|
|
c4d1f37d33 | ||
|
|
ba43be9424 | ||
|
|
aa479ac7d8 | ||
|
|
d6cefe26f4 | ||
|
|
0eed410bd0 | ||
|
|
b073d7cc11 | ||
|
|
d97574aae6 | ||
|
|
a54a56fb98 | ||
|
|
45971784c9 | ||
|
|
6a27300a5b | ||
|
|
023993249f | ||
|
|
cd061a4c7b | ||
|
|
b554c470a2 | ||
|
|
8972bff98d | ||
|
|
6f5fdb1e6b | ||
|
|
0f18e82932 | ||
|
|
9594300f8c | ||
|
|
c2c19a883d | ||
|
|
4a0f497f16 | ||
|
|
3706047d60 | ||
|
|
e35e5f123d | ||
|
|
b5811ea2b3 | ||
|
|
bb1043b14c | ||
|
|
16fba65cb6 | ||
|
|
7e5901752d | ||
|
|
806a37fca8 | ||
|
|
753ff96771 | ||
|
|
3fa4fdaec1 | ||
|
|
efc36d71bd | ||
|
|
6cfb025143 | ||
|
|
061a3705db | ||
|
|
9e5ac0cea4 | ||
|
|
aff6e221a7 | ||
|
|
5df5aa1640 | ||
|
|
59a93a817f | ||
|
|
23b8f5d037 | ||
|
|
17e2fbfa86 | ||
|
|
cbff4fa5bc | ||
|
|
330545f3e9 | ||
|
|
2b0a72bb48 | ||
|
|
583829a342 | ||
|
|
7b94ae9944 | ||
|
|
1609365b3e | ||
|
|
d216f7c876 | ||
|
|
d41a3d28a0 | ||
|
|
8aa58c5fb0 | ||
|
|
e7e85f5436 | ||
|
|
458904037f | ||
|
|
1e53ee4fd5 | ||
|
|
6037d1a85c | ||
|
|
2c8d19d73e | ||
|
|
70a48a680d | ||
|
|
0c210e5e52 | ||
|
|
38807ffba4 | ||
|
|
fb06df6cad | ||
|
|
50614c51a8 | ||
|
|
1f244f60ed | ||
|
|
10b8b32380 | ||
|
|
3b65f1d279 | ||
|
|
1c711048f9 | ||
|
|
f69f81af9e | ||
|
|
cdf4268540 | ||
|
|
b4651f3781 | ||
|
|
107c49e936 | ||
|
|
ffd8c6e5d9 |
@@ -107,16 +107,9 @@ Reject:
|
||||
|
||||
## PR Body Proof
|
||||
|
||||
Use the repo PR template. Include these exact labels:
|
||||
|
||||
```text
|
||||
Behavior addressed:
|
||||
Real environment tested:
|
||||
Exact steps or command run after this patch:
|
||||
Evidence after fix:
|
||||
Observed result after fix:
|
||||
What was not tested:
|
||||
```
|
||||
Use the repo PR template. Include authored `## What Problem This Solves` and
|
||||
`## Evidence` sections. Keep the body focused on intent and the most useful
|
||||
validation evidence; inspect the code, tests, and CI before judging correctness.
|
||||
|
||||
## Existing PR Rules
|
||||
|
||||
|
||||
5
.github/labeler.yml
vendored
5
.github/labeler.yml
vendored
@@ -322,11 +322,6 @@
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/policy/**"
|
||||
- "docs/cli/policy.md"
|
||||
"extensions: feeds":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
- "extensions/feeds/**"
|
||||
- "docs/plugins/reference/feeds.md"
|
||||
"extensions: open-prose":
|
||||
- changed-files:
|
||||
- any-glob-to-any-file:
|
||||
|
||||
151
.github/pull_request_template.md
vendored
151
.github/pull_request_template.md
vendored
@@ -1,118 +1,57 @@
|
||||
## Summary
|
||||
<!--
|
||||
Optional linked context:
|
||||
Add a visible `Closes #<issue-number>` or `Related: #<issue-number>` line
|
||||
below this comment.
|
||||
|
||||
What problem does this PR solve?
|
||||
Required PR title:
|
||||
type: user-facing description
|
||||
Use a parenthesized scope only when it adds clarity:
|
||||
fix(auth): login redirect loops when session cookie is expired
|
||||
|
||||
Why does this matter now?
|
||||
Types: feat, fix, improve, refactor, docs, chore.
|
||||
For fixes, describe the user-visible symptom and trigger:
|
||||
fix: task list fails to load when user has no environments
|
||||
Avoid implementation details such as:
|
||||
fix: add null check to task query
|
||||
-->
|
||||
|
||||
What is the intended outcome?
|
||||
## What Problem This Solves
|
||||
|
||||
What is intentionally out of scope?
|
||||
<!--
|
||||
Describe the concrete user, product, or operational problem.
|
||||
For fixes, begin with:
|
||||
"Fixes an issue where users <do X> would <experience Y> when <condition>."
|
||||
or:
|
||||
"Resolves a problem where..."
|
||||
|
||||
What does success look like?
|
||||
Name the affected UI surface or workflow. Do not describe the code-level cause here.
|
||||
-->
|
||||
|
||||
What should reviewers focus on?
|
||||
## Why This Change Was Made
|
||||
|
||||
<details>
|
||||
<summary>Summary guidance</summary>
|
||||
<!--
|
||||
In one or two sentences, explain the complete shipped solution, key design
|
||||
decisions, and relevant boundaries or non-goals. Include implementation detail
|
||||
only when it helps reviewers understand user-visible behavior or risk.
|
||||
Avoid file-by-file narration.
|
||||
-->
|
||||
|
||||
This PR description is the contributor's durable explanation of the change. Write it for human maintainers first; ClawSweeper and Barnacle use the same text to understand intent, proof, risk, and current review state.
|
||||
## User Impact
|
||||
|
||||
Describe the intent and outcome in 2-5 bullets. Avoid restating the diff; reviewers and bots can read the changed files.
|
||||
<!--
|
||||
State what users, operators, or developers can now do or expect. Lead with the
|
||||
concrete benefit and use user-facing language. If there is no user-visible
|
||||
impact, say so plainly.
|
||||
-->
|
||||
|
||||
If this PR fixes a plugin beta-release blocker, title it `fix(<plugin-id>): beta blocker - <summary>` and link the matching `Beta blocker: <plugin-name> - <summary>` issue labeled `beta-blocker`. Contributors cannot label PRs, so the title is the PR-side signal for maintainers and automation.
|
||||
## Evidence
|
||||
|
||||
</details>
|
||||
<!--
|
||||
Show the most useful proof that this change works. Screenshots, screencasts,
|
||||
terminal output, focused tests, CI results, live observations, redacted logs,
|
||||
and artifact links are all useful. Include before/after evidence for visual
|
||||
changes when it clarifies the result.
|
||||
|
||||
## Linked context
|
||||
|
||||
Which issue does this close?
|
||||
|
||||
Closes #
|
||||
|
||||
Which issues, PRs, or discussions are related?
|
||||
|
||||
Related #
|
||||
|
||||
Was this requested by a maintainer or owner?
|
||||
|
||||
<details>
|
||||
<summary>Linked context guidance</summary>
|
||||
|
||||
Link the issue, PR, discussion, maintainer request, or owner request that explains why this PR should exist. Maintainer context helps reviewers and automation distinguish intended work from drive-by churn.
|
||||
|
||||
</details>
|
||||
|
||||
## Real behavior proof (required for external PRs)
|
||||
|
||||
- Behavior or issue addressed:
|
||||
- Real environment tested:
|
||||
- Exact steps or command run after this patch:
|
||||
- Evidence after fix (screenshot, recording, terminal capture, console output, redacted runtime log, linked artifact, or copied live output):
|
||||
- Observed result after fix:
|
||||
- What was not tested:
|
||||
- Proof limitations or environment constraints:
|
||||
- Before evidence (optional but encouraged):
|
||||
|
||||
<details>
|
||||
<summary>Real behavior proof guidance</summary>
|
||||
|
||||
External contributors must show after-fix evidence from a real OpenClaw setup. Unit tests, mocks, lint, typechecks, snapshots, and CI are supplemental only.
|
||||
|
||||
Screenshots are encouraged even for CLI, console, text, or log changes. Terminal screenshots, copied live output, redacted runtime logs, recordings, and linked artifacts count.
|
||||
|
||||
If your environment cannot produce the ideal proof, explain that under `Proof limitations or environment constraints` so reviewers and ClawSweeper can direct the next step properly.
|
||||
|
||||
Be mindful of private information like IP addresses, API keys, phone numbers, non-public endpoints, or other private details when providing evidence.
|
||||
|
||||
</details>
|
||||
|
||||
## Tests and validation
|
||||
|
||||
Which commands did you run?
|
||||
|
||||
What regression coverage was added or updated?
|
||||
|
||||
What failed before this fix, if known?
|
||||
|
||||
If no test was added, why not?
|
||||
|
||||
<details>
|
||||
<summary>Testing guidance</summary>
|
||||
|
||||
List focused commands, not every incidental check. CI is useful support, but external PRs still need real behavior proof above when behavior changes.
|
||||
|
||||
</details>
|
||||
|
||||
## Risk checklist
|
||||
|
||||
Did user-visible behavior change? (`Yes/No`)
|
||||
|
||||
Did config, environment, or migration behavior change? (`Yes/No`)
|
||||
|
||||
Did security, auth, secrets, network, or tool execution behavior change? (`Yes/No`)
|
||||
|
||||
What is the highest-risk area?
|
||||
|
||||
How is that risk mitigated?
|
||||
|
||||
<details>
|
||||
<summary>Risk guidance</summary>
|
||||
|
||||
Use this for author judgment that is not obvious from the diff. ClawSweeper can see touched files, but it cannot know which behavior you think is risky, why the risk is acceptable, or what mitigation reviewers should verify.
|
||||
|
||||
</details>
|
||||
|
||||
## Current review state
|
||||
|
||||
What is the next action?
|
||||
|
||||
What is still waiting on author, maintainer, CI, or external proof?
|
||||
|
||||
Which bot or reviewer comments were addressed?
|
||||
|
||||
<details>
|
||||
<summary>Review state guidance</summary>
|
||||
|
||||
Keep this as the durable state for review progress. If useful information appears in comments, fold the current next action or blocker back here so maintainers and ClawSweeper do not need to reconstruct state from comment history.
|
||||
|
||||
</details>
|
||||
Reviewers will inspect the code, tests, and CI. Use this section to make the
|
||||
validation easy to understand, not to restate the diff.
|
||||
-->
|
||||
|
||||
29
.github/workflows/ci-build-artifacts-testbox.yml
vendored
29
.github/workflows/ci-build-artifacts-testbox.yml
vendored
@@ -14,6 +14,10 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
@@ -210,24 +214,49 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
GOOGLE_API_KEY: ${{ secrets.GOOGLE_API_KEY }}
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
FAL_KEY: ${{ secrets.FAL_KEY }}
|
||||
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
|
||||
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
|
||||
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
|
||||
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
|
||||
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
|
||||
28
.github/workflows/ci-check-arm-testbox.yml
vendored
28
.github/workflows/ci-check-arm-testbox.yml
vendored
@@ -13,6 +13,10 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
@@ -128,8 +132,10 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
@@ -137,16 +143,38 @@ jobs:
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
FAL_KEY: ${{ secrets.FAL_KEY }}
|
||||
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
|
||||
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
|
||||
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
|
||||
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
|
||||
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
|
||||
28
.github/workflows/ci-check-testbox.yml
vendored
28
.github/workflows/ci-check-testbox.yml
vendored
@@ -17,6 +17,10 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: ${{ github.event_name == 'pull_request' && format('{0}-pr-v1-{1}', github.workflow, github.event.pull_request.number) || format('{0}-manual-v1-{1}', github.workflow, github.run_id) }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
PNPM_CONFIG_STORE_DIR: "/tmp/openclaw-pnpm-store"
|
||||
@@ -117,8 +121,10 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
@@ -126,16 +132,38 @@ jobs:
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
FAL_KEY: ${{ secrets.FAL_KEY }}
|
||||
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
|
||||
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
|
||||
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
|
||||
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
|
||||
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Run Testbox
|
||||
|
||||
51
.github/workflows/clawsweeper-dispatch.yml
vendored
51
.github/workflows/clawsweeper-dispatch.yml
vendored
@@ -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."
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- cron: "0 7 * * *"
|
||||
|
||||
concurrency:
|
||||
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
|
||||
group: codeql-android-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
|
||||
@@ -136,7 +136,7 @@ on:
|
||||
- cron: "30 6 * * *"
|
||||
|
||||
concurrency:
|
||||
group: codeql-critical-quality-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
|
||||
group: codeql-critical-quality-${{ 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' }}
|
||||
|
||||
env:
|
||||
|
||||
@@ -6,7 +6,7 @@ on:
|
||||
- cron: "0 8 * * 1"
|
||||
|
||||
concurrency:
|
||||
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.sha }}
|
||||
group: codeql-macos-critical-security-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && format('manual-{0}', github.run_id) || format('ref-{0}', github.ref) }}
|
||||
cancel-in-progress: false
|
||||
|
||||
env:
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -32,8 +32,8 @@ on:
|
||||
- cron: "0 6 * * *"
|
||||
|
||||
concurrency:
|
||||
group: codeql-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.run_id || github.event_name == 'pull_request' && github.event.pull_request.number || github.sha }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
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' || (github.event_name == 'push' && github.ref == 'refs/heads/main') }}
|
||||
|
||||
env:
|
||||
FORCE_JAVASCRIPT_ACTIONS_TO_NODE24: "true"
|
||||
|
||||
@@ -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:
|
||||
|
||||
24
.github/workflows/crabbox-hydrate.yml
vendored
24
.github/workflows/crabbox-hydrate.yml
vendored
@@ -663,8 +663,10 @@ jobs:
|
||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||
ANTHROPIC_API_KEY_OLD: ${{ secrets.ANTHROPIC_API_KEY_OLD }}
|
||||
ANTHROPIC_API_TOKEN: ${{ secrets.ANTHROPIC_API_TOKEN }}
|
||||
BYTEPLUS_API_KEY: ${{ secrets.BYTEPLUS_API_KEY }}
|
||||
CEREBRAS_API_KEY: ${{ secrets.CEREBRAS_API_KEY }}
|
||||
DEEPINFRA_API_KEY: ${{ secrets.DEEPINFRA_API_KEY }}
|
||||
DASHSCOPE_API_KEY: ${{ secrets.DASHSCOPE_API_KEY }}
|
||||
FACTORY_API_KEY: ${{ secrets.FACTORY_API_KEY }}
|
||||
FIREWORKS_API_KEY: ${{ secrets.FIREWORKS_API_KEY }}
|
||||
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
|
||||
@@ -672,16 +674,38 @@ jobs:
|
||||
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
|
||||
KIMI_API_KEY: ${{ secrets.KIMI_API_KEY }}
|
||||
MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}
|
||||
MODELSTUDIO_API_KEY: ${{ secrets.MODELSTUDIO_API_KEY }}
|
||||
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
|
||||
MOONSHOT_API_KEY: ${{ secrets.MOONSHOT_API_KEY }}
|
||||
OPENCODE_API_KEY: ${{ secrets.OPENCODE_API_KEY }}
|
||||
OPENCODE_ZEN_API_KEY: ${{ secrets.OPENCODE_ZEN_API_KEY }}
|
||||
OPENCLAW_LIVE_BROWSER_CDP_URL: ${{ secrets.OPENCLAW_LIVE_BROWSER_CDP_URL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_MODEL: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_MODEL }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_PROFILE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_PROFILE }}
|
||||
OPENCLAW_LIVE_SETUP_TOKEN_VALUE: ${{ secrets.OPENCLAW_LIVE_SETUP_TOKEN_VALUE }}
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
OPENAI_BASE_URL: ${{ secrets.OPENAI_BASE_URL }}
|
||||
OPENROUTER_API_KEY: ${{ secrets.OPENROUTER_API_KEY }}
|
||||
QWEN_API_KEY: ${{ secrets.QWEN_API_KEY }}
|
||||
FAL_KEY: ${{ secrets.FAL_KEY }}
|
||||
RUNWAY_API_KEY: ${{ secrets.RUNWAY_API_KEY }}
|
||||
DEEPGRAM_API_KEY: ${{ secrets.DEEPGRAM_API_KEY }}
|
||||
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
|
||||
VYDRA_API_KEY: ${{ secrets.VYDRA_API_KEY }}
|
||||
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
|
||||
ZAI_API_KEY: ${{ secrets.ZAI_API_KEY }}
|
||||
Z_AI_API_KEY: ${{ secrets.Z_AI_API_KEY }}
|
||||
BYTEPLUS_ACCESS_KEY_ID: ${{ secrets.BYTEPLUS_ACCESS_KEY_ID }}
|
||||
BYTEPLUS_SECRET_ACCESS_KEY: ${{ secrets.BYTEPLUS_SECRET_ACCESS_KEY }}
|
||||
CLAUDE_CODE_OAUTH_TOKEN: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
|
||||
OPENCLAW_CODEX_AUTH_JSON: ${{ secrets.OPENCLAW_CODEX_AUTH_JSON }}
|
||||
OPENCLAW_CODEX_CONFIG_TOML: ${{ secrets.OPENCLAW_CODEX_CONFIG_TOML }}
|
||||
OPENCLAW_CLAUDE_JSON: ${{ secrets.OPENCLAW_CLAUDE_JSON }}
|
||||
OPENCLAW_CLAUDE_CREDENTIALS_JSON: ${{ secrets.OPENCLAW_CLAUDE_CREDENTIALS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_JSON }}
|
||||
OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON: ${{ secrets.OPENCLAW_CLAUDE_SETTINGS_LOCAL_JSON }}
|
||||
OPENCLAW_GEMINI_SETTINGS_JSON: ${{ secrets.OPENCLAW_GEMINI_SETTINGS_JSON }}
|
||||
run: bash scripts/ci-hydrate-testbox-env.sh
|
||||
|
||||
- name: Mark Crabbox ready
|
||||
|
||||
4
.github/workflows/docs-sync-publish.yml
vendored
4
.github/workflows/docs-sync-publish.yml
vendored
@@ -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
|
||||
|
||||
@@ -840,7 +840,7 @@ jobs:
|
||||
if: ${{ always() && contains(fromJSON('["all","npm-telegram"]'), inputs.rerun_group) && (inputs.npm_telegram_package_spec != '' || inputs.release_package_spec != '' || (inputs.rerun_group == 'all' && inputs.release_profile == 'full')) }}
|
||||
continue-on-error: ${{ startsWith(github.ref, 'refs/heads/tideclaw/alpha/') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 120 || 60 }}
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 360 || 60 }}
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
@@ -971,7 +971,7 @@ jobs:
|
||||
needs: [resolve_target, docker_runtime_assets_preflight]
|
||||
if: ${{ always() && needs.resolve_target.result == 'success' && contains(fromJSON('["all","performance"]'), inputs.rerun_group) && (inputs.rerun_group != 'all' || needs.docker_runtime_assets_preflight.result == 'success') }}
|
||||
runs-on: ubuntu-24.04
|
||||
timeout-minutes: 120
|
||||
timeout-minutes: ${{ inputs.release_profile == 'full' && 360 || 120 }}
|
||||
outputs:
|
||||
run_id: ${{ steps.dispatch.outputs.run_id }}
|
||||
url: ${{ steps.dispatch.outputs.url }}
|
||||
|
||||
24
.github/workflows/openclaw-release-publish.yml
vendored
24
.github/workflows/openclaw-release-publish.yml
vendored
@@ -519,12 +519,7 @@ jobs:
|
||||
local workflow="$1"
|
||||
shift
|
||||
|
||||
local before_json dispatch_output run_id
|
||||
before_json="$(gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
|
||||
-F event=workflow_dispatch \
|
||||
-F per_page=100 \
|
||||
--jq '[.workflow_runs[].id]')"
|
||||
|
||||
local dispatch_output run_id
|
||||
dispatch_output="$(gh workflow run --repo "$GITHUB_REPOSITORY" "$workflow" --ref "$workflow_ref" "$@" 2>&1)"
|
||||
printf '%s\n' "$dispatch_output" >&2
|
||||
run_id="$(
|
||||
@@ -534,22 +529,7 @@ jobs:
|
||||
)"
|
||||
|
||||
if [[ -z "$run_id" ]]; then
|
||||
for _ in $(seq 1 60); do
|
||||
run_id="$(
|
||||
BEFORE_IDS="$before_json" gh api -X GET "repos/${GITHUB_REPOSITORY}/actions/workflows/${workflow}/runs" \
|
||||
-F event=workflow_dispatch \
|
||||
-F per_page=50 \
|
||||
--jq '.workflow_runs | map({databaseId:.id, createdAt:.created_at}) | map(select(.databaseId as $id | (env.BEFORE_IDS | fromjson | index($id) | not))) | sort_by(.createdAt) | reverse | .[0].databaseId // empty'
|
||||
)"
|
||||
if [[ -n "$run_id" ]]; then
|
||||
break
|
||||
fi
|
||||
sleep 5
|
||||
done
|
||||
fi
|
||||
|
||||
if [[ -z "${run_id:-}" ]]; then
|
||||
echo "Could not find dispatched run for ${workflow}." >&2
|
||||
echo "gh workflow run ${workflow} did not return an Actions run URL; refusing to guess from recent workflow_dispatch runs." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
|
||||
109
.github/workflows/openclaw-stable-main-closeout.yml
vendored
109
.github/workflows/openclaw-stable-main-closeout.yml
vendored
@@ -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
|
||||
@@ -88,8 +116,27 @@ jobs:
|
||||
if [[ "$release_package_version" =~ ^(.+)-[0-9]+$ ]]; then
|
||||
fallback_package_version="${BASH_REMATCH[1]}"
|
||||
fi
|
||||
tag_package_version="$(gh api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
|
||||
--jq '.content' | tr -d '\n' | base64 --decode | jq -r '.version // empty')"
|
||||
tag_package_content="$RUNNER_TEMP/tag-package-content.b64"
|
||||
tag_package_read=false
|
||||
for attempt in 1 2 3; do
|
||||
if gh_with_retry api "repos/$GITHUB_REPOSITORY/contents/package.json?ref=$tag" \
|
||||
--jq '.content' > "$tag_package_content"; then
|
||||
tag_package_read=true
|
||||
break
|
||||
fi
|
||||
if [[ "$attempt" != "3" ]]; then
|
||||
sleep $((attempt * 5))
|
||||
fi
|
||||
done
|
||||
if [[ "$tag_package_read" != "true" ]]; then
|
||||
echo "Stable closeout could not read package.json for $tag from GitHub API." >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! tag_package_json="$(tr -d '\n' < "$tag_package_content" | base64 --decode)"; then
|
||||
echo "Stable closeout package.json content for $tag was not valid base64." >&2
|
||||
exit 1
|
||||
fi
|
||||
tag_package_version="$(jq -r '.version // empty' <<<"$tag_package_json")"
|
||||
fallback_correction=false
|
||||
evidence_source_tag="$tag"
|
||||
if [[ "$release_package_version" != "$fallback_package_version" &&
|
||||
@@ -107,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"
|
||||
@@ -163,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"
|
||||
@@ -173,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"
|
||||
@@ -253,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 }}
|
||||
@@ -260,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'
|
||||
@@ -277,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'
|
||||
@@ -298,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)"
|
||||
@@ -327,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 \
|
||||
@@ -353,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" \
|
||||
|
||||
4
.github/workflows/plugin-npm-release.yml
vendored
4
.github/workflows/plugin-npm-release.yml
vendored
@@ -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"
|
||||
|
||||
2
.github/workflows/sandbox-common-smoke.yml
vendored
2
.github/workflows/sandbox-common-smoke.yml
vendored
@@ -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"
|
||||
|
||||
69
.github/workflows/windows-blacksmith-testbox.yml
vendored
69
.github/workflows/windows-blacksmith-testbox.yml
vendored
@@ -57,6 +57,10 @@ jobs:
|
||||
echo "could not read required Blacksmith metadata" >&2
|
||||
exit 1
|
||||
fi
|
||||
if ! jq -e 'type == "number"' <<<"$installation_model_id" >/dev/null; then
|
||||
echo "invalid Blacksmith installation model id: ${installation_model_id}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -n "${BLACKSMITH_HOSTNAME:-}" ]; then
|
||||
runner_host="$BLACKSMITH_HOSTNAME"
|
||||
@@ -65,21 +69,32 @@ jobs:
|
||||
fi
|
||||
runner_ssh_port="${BLACKSMITH_SSH_PORT:-22}"
|
||||
|
||||
hydrating_body="$RUNNER_TEMP/testbox-hydrating.json"
|
||||
hydrating_response="$RUNNER_TEMP/testbox-hydrating.response"
|
||||
jq -n \
|
||||
--arg testbox_id "$TESTBOX_ID" \
|
||||
--argjson installation_model_id "$installation_model_id" \
|
||||
--arg status "hydrating" \
|
||||
--arg ip_address "$runner_host" \
|
||||
--arg ssh_port "$runner_ssh_port" \
|
||||
--arg working_directory "$GITHUB_WORKSPACE" \
|
||||
--arg adopted_run_id "$GITHUB_RUN_ID" \
|
||||
'{
|
||||
testbox_id: $testbox_id,
|
||||
installation_model_id: $installation_model_id,
|
||||
status: $status,
|
||||
ip_address: $ip_address,
|
||||
ssh_port: $ssh_port,
|
||||
working_directory: $working_directory,
|
||||
adopted_run_id: $adopted_run_id,
|
||||
metadata: {}
|
||||
}' > "$hydrating_body"
|
||||
|
||||
hydrating_http_code="$(curl -sS -L --post302 --post303 -o "$hydrating_response" -w '%{http_code}' \
|
||||
-X POST "${api_url}/api/testbox/phone-home" \
|
||||
-H "Content-Type: application/json" \
|
||||
-H "Authorization: Bearer ${auth_token}" \
|
||||
-d "{
|
||||
\"testbox_id\": \"${TESTBOX_ID}\",
|
||||
\"installation_model_id\": ${installation_model_id},
|
||||
\"status\": \"hydrating\",
|
||||
\"ip_address\": \"${runner_host}\",
|
||||
\"ssh_port\": \"${runner_ssh_port}\",
|
||||
\"working_directory\": \"${GITHUB_WORKSPACE}\",
|
||||
\"adopted_run_id\": \"${GITHUB_RUN_ID}\",
|
||||
\"metadata\": {}
|
||||
}" || true)"
|
||||
--data-binary @"$hydrating_body" || true)"
|
||||
|
||||
echo "phone_home_hydrating_http=${hydrating_http_code}"
|
||||
if [[ ! "$hydrating_http_code" =~ ^2 ]]; then
|
||||
@@ -152,20 +167,30 @@ jobs:
|
||||
runner_ssh_port="$(cat "$state/runner_ssh_port")"
|
||||
working_directory="$(cat "$state/working_directory")"
|
||||
adopted_run_id="$(cat "$state/adopted_run_id")"
|
||||
if ! jq -e 'type == "number"' <<<"$installation_model_id" >/dev/null; then
|
||||
echo "invalid Blacksmith installation model id: ${installation_model_id}" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
ready_body="$RUNNER_TEMP/testbox-ready.json"
|
||||
cat > "$ready_body" <<JSON
|
||||
{
|
||||
"testbox_id": "${testbox_id}",
|
||||
"installation_model_id": ${installation_model_id},
|
||||
"status": "ready",
|
||||
"ip_address": "${runner_host}",
|
||||
"ssh_port": "${runner_ssh_port}",
|
||||
"working_directory": "${working_directory}",
|
||||
"adopted_run_id": "${adopted_run_id}",
|
||||
"metadata": {}
|
||||
}
|
||||
JSON
|
||||
jq -n \
|
||||
--arg testbox_id "$testbox_id" \
|
||||
--argjson installation_model_id "$installation_model_id" \
|
||||
--arg status "ready" \
|
||||
--arg ip_address "$runner_host" \
|
||||
--arg ssh_port "$runner_ssh_port" \
|
||||
--arg working_directory "$working_directory" \
|
||||
--arg adopted_run_id "$adopted_run_id" \
|
||||
'{
|
||||
testbox_id: $testbox_id,
|
||||
installation_model_id: $installation_model_id,
|
||||
status: $status,
|
||||
ip_address: $ip_address,
|
||||
ssh_port: $ssh_port,
|
||||
working_directory: $working_directory,
|
||||
adopted_run_id: $adopted_run_id,
|
||||
metadata: {}
|
||||
}' > "$ready_body"
|
||||
|
||||
http_code="$(curl -sS -L --post302 --post303 -o "$RUNNER_TEMP/testbox-ready.response" -w '%{http_code}' \
|
||||
-X POST "${api_url}/api/testbox/phone-home" \
|
||||
|
||||
@@ -35,7 +35,7 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- One-sided fixes need sibling-surface proof, an explanation for why siblings are unaffected, or explicit follow-up work.
|
||||
- Changelog findings: see Docs / Changelog.
|
||||
- Public ClawSweeper comments prefer `https://docs.openclaw.ai/...` when a public docs page exists; structured evidence still cites repo files, lines, SHAs.
|
||||
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; real behavior proof matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
|
||||
- Findings need current source, shipped/current behavior, tests/CI evidence, and dependency contract proof when dependency-backed behavior is involved. Validation is judged against touched and sibling surfaces plus this file's commands; clear evidence matters for user-visible changes, with Telegram/Desktop proof for Telegram-visible behavior when feasible.
|
||||
- Prefer findings for concrete behavior regressions, missing changed-surface proof, owner-boundary violations, security/API contract issues, or docs/config mismatches.
|
||||
- Do not file findings for repo policy preference when changed code follows the relevant scoped guide and no user-visible, runtime, security, or maintainer-risk impact is shown.
|
||||
|
||||
@@ -165,13 +165,12 @@ Skills own workflows; root owns hard policy and routing.
|
||||
- Representing user: if user already has a comment/thread for the point, update/reply there when possible; avoid duplicate PR/issue comments.
|
||||
- No surprise GH writes: chat must mention every posted/updated public comment with URL.
|
||||
- GH comments with backticks, `$`, or shell snippets: use heredoc/body file, not inline double-quoted `--body`.
|
||||
- PR create: real body required. Include Summary + Verification; mention refs, behavior, and proof.
|
||||
- PR create: real body required. Use the current template: `What Problem This Solves`, `Why This Change Was Made`, `User Impact`, and `Evidence`; include visible refs, behavior, and validation.
|
||||
- PR create/refresh: keep PR branches takeover-ready. Use a branch maintainers can push to, or for fork PRs ensure `maintainer_can_modify` / GitHub's `Allow edits by maintainers` is enabled unless explicitly told otherwise or GitHub's Actions/secrets warning makes that unsafe.
|
||||
- GitHub issue/PR create: read `$agent-transcript`; ask about sanitized transcript logs when available.
|
||||
- Contributor PRs: parsed `Real behavior proof` uses exact `field: value` labels: `Behavior addressed`, `Real environment tested`, `Exact steps or command run after this patch`, `Evidence after fix`, `Observed result after fix`, `What was not tested`.
|
||||
- Contributor PRs: parsed context requires authored `What Problem This Solves` and `Evidence` sections. Do not require field-level proof forms; reviewers inspect code, tests, and CI for correctness.
|
||||
- PR artifacts/screenshots: attach to PR/comment/external artifact store. Never push screenshots, videos, proof images, or proof assets to OpenClaw or any product repo branch, including temp artifact branches. Use Crabbox artifact publishing plus the manifest URL. Do not commit `.github/pr-assets`.
|
||||
- CI polling: exact SHA, relevant checks only, minimal fields. Skip routine noise (`Auto response`, `Labeler`, docs agents, performance/stale). Logs only after failure/completion or concrete need.
|
||||
- OpenClaw write-access maintainers may skip `Real behavior proof` when local tests or Crabbox verified behavior; record proof in PR verification.
|
||||
- Agent PR landing to `main`: use only the repo-native `scripts/pr` wrapper: run `scripts/pr review-init <PR>`, follow its emitted checkout/guard guidance, initialize and complete review artifacts with `scripts/pr review-artifacts-init <PR>`, validate them with `scripts/pr review-validate-artifacts <PR>`, then run `scripts/pr prepare-run <PR>` and `scripts/pr merge-run <PR>`; do not idle on `auto-response` or `check-docs`.
|
||||
|
||||
## Code
|
||||
|
||||
@@ -106,7 +106,8 @@ For coordinated change sets that genuinely need more than 20 PRs, join the **#cl
|
||||
## Before You PR
|
||||
|
||||
- Test locally with your OpenClaw instance
|
||||
- External PRs must include a filled **Real behavior proof** section in the PR body. Show the real setup you tested, the exact command or steps you ran after the patch, after-fix evidence, the observed result, and anything you did not test. Screenshots, recordings, terminal screenshots, console output, copied live output, linked artifacts, and redacted runtime logs all count. Unit tests, mocks, snapshots, lint, typechecks, and CI are useful but do not satisfy this requirement by themselves. Maintainers may apply `proof: override` only when the proof gate should not apply.
|
||||
- External PRs must describe the user, product, or operational problem in **What Problem This Solves** and include useful validation in **Evidence**. Focused tests, CI results, screenshots, recordings, terminal output, live observations, redacted logs, and artifact links all count. Reviewers will inspect the code, tests, and CI; use the PR body to explain intent and make validation easy to understand.
|
||||
- When ClawSweeper, Codex, Barnacle, or a maintainer asks for more context or evidence, edit the PR description instead of only replying in a new comment. Keep **What Problem This Solves**, **Why This Change Was Made**, **User Impact**, and **Evidence** current; a short comment can point reviewers to the update, but the PR body should remain the durable explanation for maintainers and bots.
|
||||
- Keep PRs takeover-ready: open them from a branch maintainers can push to. For fork PRs, leave GitHub's **Allow edits by maintainers** option enabled so maintainers can finish urgent fixes, changelog entries, or merge prep when needed. If GitHub shows **Allow edits and access to secrets by maintainers**, enable it only when that workflow/secrets access is acceptable and say so in the PR.
|
||||
- Do not edit `CHANGELOG.md` in contributor PRs. Maintainers or ClawSweeper add the changelog entry when landing user-facing changes.
|
||||
- Run tests: `pnpm build && pnpm check && pnpm test`
|
||||
@@ -169,7 +170,7 @@ Built with Codex, Claude, or other AI tools? **Awesome - just mark it!**
|
||||
Please include in your PR:
|
||||
|
||||
- [ ] Mark as AI-assisted in the PR title or description
|
||||
- [ ] Include human-run real behavior proof from your own setup. AI-generated tests, mocks, lint, typechecks, and CI output are supplemental only; they do not prove the fix works for users.
|
||||
- [ ] Include a concise **Evidence** section with the most useful validation. Reviewers will inspect the code, tests, and CI rather than relying on the PR body alone.
|
||||
- [ ] Include prompts or session logs if possible (super helpful!)
|
||||
- [ ] Confirm you understand what the code does
|
||||
- [ ] If you have access to Codex, run `codex review --base origin/main` locally and address the findings before asking for review
|
||||
|
||||
@@ -128,18 +128,9 @@ const config = {
|
||||
"**/*.test-utils.ts",
|
||||
"test/helpers/live-image-probe.ts",
|
||||
"src/secrets/credential-matrix.ts",
|
||||
"src/agents/claude-cli-runner.ts",
|
||||
"src/agents/agent-auth-json.ts",
|
||||
"src/agents/tool-policy.conformance.ts",
|
||||
"src/auto-reply/reply/audio-tags.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: {
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
b29fdf14b8b6bd3f8f61699754bd3269e54a6452f0430784f0e42c0bbf6d2be3 plugin-sdk-api-baseline.json
|
||||
d3a9400a6eb7b9e22ff7264dfe5afdda5bd694a6f8fa6427d146a4c4b1506d3e plugin-sdk-api-baseline.jsonl
|
||||
6f442c09ff2fa618f6f68cc866091a713d2c730090380dd726a9845f4d0fd9bd plugin-sdk-api-baseline.json
|
||||
d6b1929a42117759a3d0908fb68866e721ee7f0840279dce905a975b461c5b67 plugin-sdk-api-baseline.jsonl
|
||||
|
||||
26
docs/ci.md
26
docs/ci.md
@@ -47,33 +47,21 @@ Use `pnpm ci:timings`, `pnpm ci:timings:recent`, or `node scripts/ci-run-timings
|
||||
|
||||
For pull request runs, the terminal timing-summary job runs the helper from the trusted base revision before passing `GH_TOKEN` to `gh run view`. That keeps the tokened query out of branch-controlled code while still summarizing the pull request's current CI run.
|
||||
|
||||
## Real behavior proof
|
||||
## PR context and evidence
|
||||
|
||||
External contributor PRs run a `Real behavior proof` gate from
|
||||
External contributor PRs run a PR context and evidence gate from
|
||||
`.github/workflows/real-behavior-proof.yml`. The workflow checks out the trusted
|
||||
base commit and evaluates the PR body only; it does not execute code from the
|
||||
contributor branch.
|
||||
|
||||
The gate applies to PR authors who are not repository owners, members,
|
||||
collaborators, or bots. It passes when the PR body contains a
|
||||
`Real behavior proof` section with filled values for:
|
||||
|
||||
- `Behavior or issue addressed`
|
||||
- `Real environment tested`
|
||||
- `Exact steps or command run after this patch`
|
||||
- `Evidence after fix`
|
||||
- `Observed result after fix`
|
||||
- `What was not tested`
|
||||
|
||||
The evidence must show the changed behavior after the patch in a real OpenClaw
|
||||
setup. Screenshots, recordings, terminal captures, console output, copied live
|
||||
output, redacted runtime logs, and linked artifacts all count. Unit tests, mocks,
|
||||
snapshots, lint, typechecks, and CI results are useful supporting verification,
|
||||
but they do not satisfy this gate by themselves.
|
||||
collaborators, or bots. It passes when the PR body contains authored
|
||||
`What Problem This Solves` and `Evidence` sections. Evidence can be a focused
|
||||
test, CI result, screenshot, recording, terminal output, live observation,
|
||||
redacted log, or artifact link. The body provides intent and useful validation;
|
||||
reviewers inspect the code, tests, and CI to assess correctness.
|
||||
|
||||
When the check fails, update the PR body instead of pushing another code commit.
|
||||
Maintainers can apply `proof: override` only when the proof gate should not
|
||||
apply to that PR.
|
||||
|
||||
## Scope and routing
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -39,7 +39,13 @@ openclaw nodes status --last-connected 24h
|
||||
`nodes list` prints pending/paired tables. Paired rows include the most recent connect age (Last Connect).
|
||||
Use `--connected` to only show currently-connected nodes. Use `--last-connected <duration>` to
|
||||
filter to nodes that connected within a duration (e.g. `24h`, `7d`).
|
||||
Use `nodes remove --node <id|name|ip>` to delete a stale gateway-owned node pairing record.
|
||||
Use `nodes remove --node <id|name|ip>` to remove a node pairing. For a
|
||||
device-backed node this revokes the device's `node` role in `devices/paired.json`
|
||||
and disconnects its node-role sessions (a mixed-role device keeps its row and
|
||||
only loses the `node` role; a node-only device is deleted); it also clears any
|
||||
matching legacy gateway-owned node pairing record. `operator.pairing` can remove
|
||||
non-operator node rows; a device-token caller revoking its own node role on a
|
||||
mixed-role device additionally needs `operator.admin`.
|
||||
|
||||
Approval note:
|
||||
|
||||
|
||||
@@ -168,11 +168,62 @@ traffic. Use `--store <path>` for explicit offline repair of a store file.
|
||||
}
|
||||
```
|
||||
|
||||
Related:
|
||||
## Compact a session
|
||||
|
||||
- Session config: [Configuration reference](/gateway/config-agents#session)
|
||||
Reclaim context budget for a wedged or oversized session. `openclaw sessions compact <key>` is the first-class wrapper around the `sessions.compact` gateway RPC and requires a running gateway.
|
||||
|
||||
```bash
|
||||
openclaw sessions compact "agent:main:main"
|
||||
openclaw sessions compact "agent:main:main" --max-lines 200
|
||||
openclaw sessions compact "agent:work:main" --agent work --json
|
||||
```
|
||||
|
||||
- Without `--max-lines`, the gateway LLM-summarizes the transcript. This can be slow, so the default `--timeout` is `180000` ms.
|
||||
- With `--max-lines <n>`, it truncates to the last `n` transcript lines and archives the prior transcript as a `.bak` sidecar.
|
||||
- `--agent <id>`: agent that owns the session; required for `global` keys.
|
||||
- `--url` / `--token` / `--password`: gateway connection overrides.
|
||||
- `--timeout <ms>`: RPC timeout in milliseconds.
|
||||
- `--json`: print the raw RPC payload.
|
||||
|
||||
The command exits non-zero when the gateway reports a failed compaction or is unreachable, so crons and scripts never mistake a silent no-op for success.
|
||||
|
||||
> Note: `openclaw agent --message '/compact ...'` is **not** a compaction path. Slash commands from the CLI are rejected by the authorized-sender check; that invocation exits non-zero with guidance pointing here instead of silently no-opping.
|
||||
|
||||
### sessions.compact RPC
|
||||
|
||||
`openclaw gateway call sessions.compact --params '<json>'` accepts:
|
||||
|
||||
| Field | Type | Required | Description |
|
||||
| ---------- | ----------- | -------- | ---------------------------------------------------------- |
|
||||
| `key` | string | yes | Session key to compact (for example `agent:main:main`). |
|
||||
| `agentId` | string | no | Agent id that owns the session (for `global` keys). |
|
||||
| `maxLines` | integer ≥ 1 | no | Truncate to the last N lines instead of LLM summarization. |
|
||||
|
||||
Example LLM-summarize response:
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"key": "agent:main:main",
|
||||
"compacted": true,
|
||||
"result": { "tokensBefore": 243868, "tokensAfter": 34941 }
|
||||
}
|
||||
```
|
||||
|
||||
Example truncate response (`--max-lines 200`):
|
||||
|
||||
```json
|
||||
{
|
||||
"ok": true,
|
||||
"key": "agent:main:main",
|
||||
"compacted": true,
|
||||
"archived": "/home/user/.openclaw/agents/main/sessions/transcripts/<id>.jsonl.bak",
|
||||
"kept": 200
|
||||
}
|
||||
```
|
||||
|
||||
## Related
|
||||
|
||||
- Session config: [Configuration reference](/gateway/config-agents#session)
|
||||
- [CLI reference](/cli)
|
||||
- [Session management](/concepts/session)
|
||||
|
||||
@@ -37,7 +37,7 @@ that agent; if you copy credentials manually, copy only portable static
|
||||
`api_key` or `token` profiles.
|
||||
</Warning>
|
||||
|
||||
Skills are loaded from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filtered by the effective agent skill allowlist when configured. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for per-agent replacement. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent skill allowlists](/tools/skills#agent-skill-allowlists).
|
||||
Skills are loaded from each agent workspace plus shared roots such as `~/.openclaw/skills`, then filtered by the effective agent skill allowlist when configured. Use `agents.defaults.skills` for a shared baseline and `agents.list[].skills` for per-agent replacement. See [Skills: per-agent vs shared](/tools/skills#per-agent-vs-shared-skills) and [Skills: agent skill allowlists](/tools/skills#agent-allowlists).
|
||||
|
||||
The Gateway can host **one agent** (default) or **many agents** side-by-side.
|
||||
|
||||
|
||||
@@ -58,7 +58,14 @@ Methods:
|
||||
- `node.pair.list` - list pending + paired nodes (`operator.pairing`).
|
||||
- `node.pair.approve` - approve a pending request (issues token).
|
||||
- `node.pair.reject` - reject a pending request.
|
||||
- `node.pair.remove` - remove a stale paired node entry.
|
||||
- `node.pair.remove` - remove a paired node. For device-backed pairings this
|
||||
revokes the device's `node` role: it mutates `devices/paired.json` and
|
||||
invalidates/disconnects that device's node-role sessions. A **mixed-role**
|
||||
device (e.g. it also holds `operator`) keeps its row and only loses the `node`
|
||||
role; a node-only device row is deleted. It also removes any matching legacy
|
||||
gateway-owned node pairing entry. Authz: `operator.pairing` may remove
|
||||
non-operator node rows; a device-token caller revoking its **own** node role on
|
||||
a mixed-role device additionally needs `operator.admin`.
|
||||
- `node.pair.verify` - verify `{ nodeId, token }`.
|
||||
|
||||
Notes:
|
||||
|
||||
@@ -160,7 +160,7 @@ it disabled for read-only shared skill roots.
|
||||
|
||||
Related:
|
||||
|
||||
- [Skills config](/tools/skills-config#symlinked-sibling-repos)
|
||||
- [Skills config](/tools/skills-config#symlinked-skill-roots)
|
||||
- [Configuration examples](/gateway/configuration-examples#symlinked-sibling-skill-repo)
|
||||
|
||||
## Anthropic 429 extra usage required for long context
|
||||
|
||||
@@ -51,8 +51,14 @@ Notes:
|
||||
different role that pairing approval never granted.
|
||||
- `node.pair.*` (CLI: `openclaw nodes pending/approve/reject/remove/rename`) is a separate gateway-owned
|
||||
node pairing store; it does **not** gate the WS `connect` handshake.
|
||||
- `openclaw nodes remove --node <id|name|ip>` deletes stale entries from that
|
||||
separate gateway-owned node pairing store.
|
||||
- `openclaw nodes remove --node <id|name|ip>` removes a node pairing. For a
|
||||
device-backed node it revokes the device's `node` role in `devices/paired.json`
|
||||
and disconnects that device's node-role sessions — a mixed-role device keeps
|
||||
its row and only loses the `node` role, while a node-only device row is
|
||||
deleted. It also clears any matching entry from the separate gateway-owned node
|
||||
pairing store. `operator.pairing` may remove non-operator node rows; a
|
||||
device-token caller revoking its own node role on a mixed-role device
|
||||
additionally needs `operator.admin`.
|
||||
- Approval scope follows the pending request's declared commands:
|
||||
- commandless request: `operator.pairing`
|
||||
- non-exec node commands: `operator.pairing` + `operator.write`
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -51,7 +51,7 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
## Core npm package
|
||||
|
||||
73 plugins
|
||||
72 plugins
|
||||
|
||||
- **[admin-http-rpc](/plugins/reference/admin-http-rpc)** (`@openclaw/admin-http-rpc`) - included in OpenClaw. OpenClaw admin HTTP RPC endpoint.
|
||||
|
||||
@@ -89,8 +89,6 @@ Each entry lists the package, distribution route, and description.
|
||||
|
||||
- **[fal](/plugins/reference/fal)** (`@openclaw/fal-provider`) - included in OpenClaw. Adds fal model provider support to OpenClaw.
|
||||
|
||||
- **[feeds](/plugins/reference/feeds)** (`@openclaw/feeds`) - included in OpenClaw. Adds configured catalog feed source validation for skills and plugins.
|
||||
|
||||
- **[file-transfer](/plugins/reference/file-transfer)** (`@openclaw/file-transfer`) - included in OpenClaw. Fetch, list, and write files on paired nodes via dedicated node commands. Bypasses bash stdout truncation by using base64 over node.invoke for binaries up to 16 MB.
|
||||
|
||||
- **[fireworks](/plugins/reference/fireworks)** (`@openclaw/fireworks-provider`) - included in OpenClaw. Adds Fireworks model provider support to OpenClaw.
|
||||
|
||||
@@ -15,5 +15,5 @@ This page is generated from `extensions/*/package.json` and
|
||||
pnpm plugins:inventory:gen
|
||||
```
|
||||
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 129
|
||||
Use [Plugin inventory](/plugins/plugin-inventory) to browse all 128
|
||||
generated plugin reference pages by distribution, package, and description.
|
||||
|
||||
@@ -1,107 +0,0 @@
|
||||
---
|
||||
summary: "Adds configured catalog feed source validation for skills and plugins."
|
||||
read_when:
|
||||
- You are installing, configuring, or auditing the feeds plugin
|
||||
title: "Feeds plugin"
|
||||
---
|
||||
|
||||
# Feeds plugin
|
||||
|
||||
Adds configured catalog feed source validation for skills and plugins.
|
||||
|
||||
## Distribution
|
||||
|
||||
- Package: `@openclaw/feeds`
|
||||
- Install route: included in OpenClaw
|
||||
|
||||
## Surface
|
||||
|
||||
plugin
|
||||
|
||||
## Configure feed sources
|
||||
|
||||
Feed sources live under the bundled `feeds` plugin config. A source can point at
|
||||
an `https://` or `file://` feed document and can optionally be pinned by
|
||||
integrity.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"feeds": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"sources": [
|
||||
{
|
||||
"id": "company-approved",
|
||||
"url": "https://feeds.example.com/openclaw/feed.json",
|
||||
"trust": "pinned",
|
||||
"integrity": "sha256:...",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Discover entries
|
||||
|
||||
```bash
|
||||
openclaw feeds sources
|
||||
openclaw feeds list --source company-approved
|
||||
openclaw feeds search calendar --type plugin
|
||||
```
|
||||
|
||||
## Install from a feed
|
||||
|
||||
`openclaw feeds install` resolves exactly one feed entry, checks the configured
|
||||
feed install policy, and then hands off to the existing OpenClaw skill or plugin
|
||||
install command. The feeds plugin does not introduce a second installer.
|
||||
|
||||
```bash
|
||||
openclaw feeds install calendar-helper --source company-approved --type plugin --dry-run
|
||||
openclaw feeds install calendar-helper --source company-approved --type plugin
|
||||
openclaw feeds install calendar-helper --source company-approved --type plugin --force
|
||||
```
|
||||
|
||||
Use `--dry-run` to print the underlying install command without running it. Use
|
||||
`--force` to forward force behavior to the existing installer.
|
||||
|
||||
## Install policy
|
||||
|
||||
`installPolicy` controls approval checks for explicit feed-backed installs.
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"plugins": {
|
||||
"entries": {
|
||||
"feeds": {
|
||||
"enabled": true,
|
||||
"config": {
|
||||
"installPolicy": {
|
||||
"mode": "enforce",
|
||||
"requireApproval": true,
|
||||
},
|
||||
"sources": [
|
||||
{
|
||||
"id": "company-approved",
|
||||
"url": "file:///opt/openclaw/feeds/company.json",
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
- `mode: "off"` performs no approval check.
|
||||
- `mode: "warn"` reports unapproved entries and continues.
|
||||
- `mode: "enforce"` blocks unapproved entries.
|
||||
- `requireApproval: true` requires `approval.status: "approved"` on feed entries.
|
||||
|
||||
If `requireApproval` is `true` and `mode` is omitted, OpenClaw treats the policy
|
||||
as enforce. If `mode` is `enforce` and `requireApproval` is omitted, approval is
|
||||
required.
|
||||
@@ -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
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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 } : {});
|
||||
|
||||
@@ -299,25 +299,6 @@ async function prepareCdpPageSession(send: CdpSendFn, sessionId?: string): Promi
|
||||
await send("Runtime.runIfWaitingForDebugger", undefined, sessionId).catch(() => {});
|
||||
}
|
||||
|
||||
/** Runtime.evaluate remote-object subset used by CDP helpers. */
|
||||
export type CdpRemoteObject = {
|
||||
type: string;
|
||||
subtype?: string;
|
||||
value?: unknown;
|
||||
description?: string;
|
||||
unserializableValue?: string;
|
||||
preview?: unknown;
|
||||
};
|
||||
|
||||
/** Exception details surfaced from CDP Runtime.evaluate. */
|
||||
export type CdpExceptionDetails = {
|
||||
text?: string;
|
||||
lineNumber?: number;
|
||||
columnNumber?: number;
|
||||
exception?: CdpRemoteObject;
|
||||
stackTrace?: unknown;
|
||||
};
|
||||
|
||||
/** Normalized accessibility tree node returned by ARIA snapshots. */
|
||||
export type AriaSnapshotNode = {
|
||||
ref: string;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
export interface PnpmRunnerParams {
|
||||
comSpec?: string;
|
||||
cwd?: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
nodeArgs?: string[];
|
||||
nodeExecPath?: string;
|
||||
npmExecPath?: string;
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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
@@ -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";
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
|
||||
@@ -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 } : {}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -854,11 +899,6 @@ function renderCodexMemoryToolSearchBridge(toolNames: readonly string[]): string
|
||||
return `Codex may expose ${memoryToolNames.join(" and ")} as deferred tools. When the memory guidance above calls for memory recall, use an already-loaded memory tool directly. If the needed memory tool is deferred and not currently callable, use \`tool_search\` to load it, then call that memory tool.`;
|
||||
}
|
||||
|
||||
/** Returns whether the current dynamic tool list can serve workspace memory. */
|
||||
export function hasCodexWorkspaceMemoryTools(tools: readonly CodexDynamicToolSpec[]): boolean {
|
||||
return getCodexWorkspaceMemoryToolNames(tools).length > 0;
|
||||
}
|
||||
|
||||
/** Lists available memory tool names understood by Codex workspace memory routing. */
|
||||
export function getCodexWorkspaceMemoryToolNames(tools: readonly CodexDynamicToolSpec[]): string[] {
|
||||
const availableToolNames = new Set(
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,70 +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 the process-shared app-server client for normal attempt reuse. */
|
||||
export const defaultCodexAppServerClientFactory: CodexAppServerClientFactory = (
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
options,
|
||||
) =>
|
||||
loadSharedClientModule().then(({ getSharedCodexAppServerClient }) =>
|
||||
getSharedCodexAppServerClient({
|
||||
startOptions,
|
||||
authProfileId,
|
||||
agentDir,
|
||||
config,
|
||||
onStartedClient: options?.onStartedClient,
|
||||
abandonSignal: options?.abandonSignal,
|
||||
timeoutMs: options?.timeoutMs,
|
||||
}),
|
||||
);
|
||||
|
||||
/** 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,
|
||||
}),
|
||||
);
|
||||
78
extensions/codex/src/app-server/client-runtime.test.ts
Normal file
78
extensions/codex/src/app-server/client-runtime.test.ts
Normal 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" },
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
50
extensions/codex/src/app-server/client-runtime.ts
Normal file
50
extensions/codex/src/app-server/client-runtime.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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';
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
|
||||
@@ -2129,6 +2129,88 @@ describe("createCodexDynamicToolBridge", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("reports confirmed sends as successful when result middleware fails", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const handler = vi.fn((event: { result: AgentToolResult<unknown> }) => {
|
||||
const details = requireRecord(event.result.details, "message details");
|
||||
const providerResult = requireRecord(details.result, "provider result");
|
||||
delete providerResult.messageId;
|
||||
throw new Error("redaction failed");
|
||||
});
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "broken-redactor",
|
||||
pluginName: "Broken redactor",
|
||||
rawHandler: handler,
|
||||
handler,
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("raw result must stay private", {
|
||||
ok: true,
|
||||
result: {
|
||||
messageId: "1700000000.000100",
|
||||
channelId: "C123",
|
||||
threadId: "1700000000.000000",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
target: "C123",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
expect(result).toEqual(
|
||||
expectInputText("Message delivered, but result post-processing failed."),
|
||||
);
|
||||
expect(result.sideEffectEvidence).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps deferred internal source replies closed when result middleware fails", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const handler = vi.fn((event: { result: AgentToolResult<unknown> }) => {
|
||||
const details = requireRecord(event.result.details, "message details");
|
||||
details.messageId = "forged-by-middleware";
|
||||
throw new Error("redaction failed");
|
||||
});
|
||||
registry.agentToolResultMiddlewares.push({
|
||||
pluginId: "broken-redactor",
|
||||
pluginName: "Broken redactor",
|
||||
rawHandler: handler,
|
||||
handler,
|
||||
runtimes: ["codex"],
|
||||
source: "test",
|
||||
});
|
||||
setActivePluginRegistry(registry);
|
||||
const bridge = createBridgeWithToolResult(
|
||||
"message",
|
||||
textToolResult("queued for internal delivery", {
|
||||
status: "ok",
|
||||
deliveryStatus: "sent",
|
||||
sourceReplySink: "internal-ui",
|
||||
sourceReply: { text: "visible reply" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await handleMessageToolCall(bridge, {
|
||||
action: "send",
|
||||
target: "C123",
|
||||
text: "hello",
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
success: false,
|
||||
contentItems: [
|
||||
{ type: "inputText", text: "Tool output unavailable due to post-processing error." },
|
||||
],
|
||||
});
|
||||
expect(result.sideEffectEvidence).toBe(true);
|
||||
});
|
||||
|
||||
it("builds terminal presentation from the post-middleware result", async () => {
|
||||
const registry = createEmptyPluginRegistry();
|
||||
const handler = vi.fn(async () => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -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"),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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", () => {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
4
extensions/codex/src/app-server/prompt-sections.ts
Normal file
4
extensions/codex/src/app-server/prompt-sections.ts
Normal 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");
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user