Compare commits

..

601 Commits

Author SHA1 Message Date
Peter Steinberger
c535f6d3f3 docs: document daemon shared helper contracts 2026-06-01 13:15:52 -04:00
Peter Steinberger
baaf6f8d1c docs: document CLI startup runtime helpers 2026-06-01 13:13:41 -04:00
Peter Steinberger
01e7733deb docs: document config set helper contracts 2026-06-01 13:12:12 -04:00
Peter Steinberger
66425c3406 docs: document CLI utility helper contracts 2026-06-01 13:10:43 -04:00
Peter Steinberger
f6d05d604b docs: document CLI banner contracts 2026-06-01 13:09:39 -04:00
Peter Steinberger
92a676fc71 docs: document CLI completion contracts 2026-06-01 13:08:22 -04:00
Peter Steinberger
4e5835c038 docs: document CLI command policy contracts 2026-06-01 13:07:15 -04:00
Peter Steinberger
c4686e50c2 docs: document CLI invocation display helpers 2026-06-01 13:05:49 -04:00
Peter Steinberger
3764ff6b84 docs: document CLI argv helpers 2026-06-01 13:04:38 -04:00
Peter Steinberger
242995a3af docs: document CLI root option helpers 2026-06-01 13:03:06 -04:00
Peter Steinberger
4ce258ae9b docs: document approval handler runtime contracts 2026-06-01 13:01:14 -04:00
Peter Steinberger
b132ca0183 docs: document command carrier exports 2026-06-01 12:59:09 -04:00
Peter Steinberger
4c556fc09f docs: document offsetless datetime parsing 2026-06-01 12:57:30 -04:00
Peter Steinberger
98e05f8754 docs: document duration formatter contract 2026-06-01 12:55:42 -04:00
Peter Steinberger
05df67dd70 docs: document lsof resolver contracts 2026-06-01 12:53:09 -04:00
Peter Steinberger
30d6a53681 docs: document port diagnostics contracts 2026-06-01 12:51:27 -04:00
Peter Steinberger
3c25345fd5 docs: document binary detection contract 2026-06-01 12:49:41 -04:00
Peter Steinberger
11d7a51844 docs: document git root contracts 2026-06-01 12:48:01 -04:00
Peter Steinberger
38da14ac55 docs: document package json contracts 2026-06-01 12:46:27 -04:00
Peter Steinberger
eb7ec0e620 docs: document runtime status contract 2026-06-01 12:44:36 -04:00
Peter Steinberger
9fe0862e4b docs: document WebSocket raw data contract 2026-06-01 12:43:10 -04:00
Peter Steinberger
4667b7cca2 docs: document secure random contracts 2026-06-01 12:41:28 -04:00
Peter Steinberger
a8b695a944 docs: document JSON byte contracts 2026-06-01 12:40:06 -04:00
Peter Steinberger
ba6af56f48 docs: document path prepend contracts 2026-06-01 12:38:42 -04:00
Peter Steinberger
412fb4b32e docs: document port probe contract 2026-06-01 12:37:14 -04:00
Peter Steinberger
6fa07e83bd docs: document install target contracts 2026-06-01 12:35:45 -04:00
Peter Steinberger
889fc5fa91 docs: document update channel contracts 2026-06-01 12:34:00 -04:00
Peter Steinberger
9bfb81d64e docs: document update check contracts 2026-06-01 12:31:39 -04:00
Peter Steinberger
08b953d111 docs: document update restart sentinel handoff 2026-06-01 12:28:52 -04:00
Peter Steinberger
d10427f45c docs: document restart sentinel continuations 2026-06-01 12:27:22 -04:00
Peter Steinberger
c6a49588aa docs: document entrypoint detection 2026-06-01 12:25:37 -04:00
Peter Steinberger
187cfdf385 docs: document npm registry spec contracts 2026-06-01 12:24:09 -04:00
Peter Steinberger
724bdbb1bd docs: document secret input normalization 2026-06-01 12:22:48 -04:00
Peter Steinberger
5a6a6db65d docs: document inline directive helpers 2026-06-01 12:21:36 -04:00
Peter Steinberger
daf2b631e0 docs: document delivery context helpers 2026-06-01 12:20:18 -04:00
Peter Steinberger
ba9993229f docs: document message channel helpers 2026-06-01 12:18:51 -04:00
Peter Steinberger
021252e214 docs: document shared utils contracts 2026-06-01 12:17:31 -04:00
Peter Steinberger
5c00de15f5 docs: document ssrf contracts 2026-06-01 12:16:19 -04:00
Peter Steinberger
ba97b0484d docs: document local-origin bypass contracts 2026-06-01 12:15:13 -04:00
Peter Steinberger
e2c5e19876 docs: document proxy fetch contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
b3d9bf8f55 docs: document runtime fetch contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
59e8c8a166 docs: document fetch compatibility contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
a8019540bd docs: document web push contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
55a44bb7ae docs: document Homebrew resolution contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
2821654f38 docs: document gateway discovery targets 2026-06-01 12:13:49 -04:00
Peter Steinberger
22855ab94e docs: document gateway process contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
10eec59169 docs: document host env security contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
affa47c13b docs: document restart sentinel contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
d39becd739 docs: document port diagnostics contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
eb8a1b6877 docs: document heartbeat cooldown contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
f3608d08b4 docs: document OpenAI tool choice contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
e6dec97e75 docs: document temp directory contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
80e2bfbd16 docs: document executable path contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
a5c8558689 docs: document MCP loopback contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
aca296e92b docs: document infra utility contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
dba817386a docs: document npm registry spec contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
f8d93befac docs: document gateway shared auth contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
887ebc95fa docs: document gateway method scope contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
54ebb9d08f docs: document agent stream safety contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
ce4f471206 docs: document CLI session reuse contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
ae606118b4 docs: document node pairing surface contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
30c0c1352f docs: document node pairing persistence contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
94a92a78f4 docs: document gateway security helper contracts 2026-06-01 12:13:49 -04:00
Peter Steinberger
e4539d2756 docs: document gateway update mutation contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
42f1c9e3d4 docs: document node wake rpc contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
ade91600bc docs: document node approval pairing contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
5fe49e3f9d docs: document gateway node registry contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
518eff785e docs: document gateway startup runtime contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
f1635142d8 docs: document gateway startup config contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
ee39f5d282 docs: document gateway talk session registry contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
17b35107a9 docs: document gateway talk relay contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
3d798f4e8e docs: document gateway session history contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
81dcefe261 docs: document gateway subagent lineage contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
c847c89dbb docs: document session lifecycle event contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
690d79b32a docs: document gateway active session shutdown contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
593d97b9ca docs: document gateway transcript ownership contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
5e88b8b5af docs: document gateway session patch contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
c57c27016a docs: document gateway auth resolution contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
1f0c7847a6 docs: document gateway auth secret materialization 2026-06-01 12:13:48 -04:00
Peter Steinberger
8a475b6631 docs: document gateway explicit connection policy 2026-06-01 12:13:48 -04:00
Peter Steinberger
d48a8e53bb docs: document control ui routing contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
845851cc78 docs: document gateway utility contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
03abdfea2c docs: document gateway run session key contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
eb16425492 docs: document node invoke sanitizer contract 2026-06-01 12:13:48 -04:00
Peter Steinberger
64fcdba480 docs: document gateway rate limit contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
ca0e791b4a docs: document gateway request context contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
76b69cfecb docs: document gateway shared auth generation 2026-06-01 12:13:48 -04:00
Peter Steinberger
080b453592 docs: document gateway session key contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
fa4f5044f5 docs: document gateway credential secret contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
e144252720 docs: document gateway method contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
646522aaa3 docs: document plugin main api contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
10c39f6da5 docs: document plugin node service contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
c07ae4e067 docs: document plugin command gateway contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
db0d6d750f docs: document speech realtime provider contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
e51e9c327c docs: document provider plugin tail contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
c057a31564 docs: document provider plugin identity contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
59cb1a2be3 docs: document provider setup prompt contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
ece65f4e24 docs: document provider runtime hook contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
266380f6c0 docs: document provider catalog type contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
84914e4dc8 docs: document provider auth method contracts 2026-06-01 12:13:48 -04:00
Peter Steinberger
5f51677454 docs: document provider auth type contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
39fa88a1e4 docs: document migration provider type contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
8a42725d38 docs: document migrate prompt option APIs 2026-06-01 12:13:47 -04:00
Peter Steinberger
ed4f308d28 docs: document migrate command entrypoint 2026-06-01 12:13:47 -04:00
Peter Steinberger
e58cba2797 docs: document migrate provider context helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
088d228e71 docs: document migrate output helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
cb55aa2ab1 docs: document migrate selection helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
1705b12dea docs: document plugin SDK migration runtime 2026-06-01 12:13:47 -04:00
Peter Steinberger
a605c11b6f docs: document plugin SDK migration helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
d31966726b docs: document provider tool schema compatibility 2026-06-01 12:13:47 -04:00
Peter Steinberger
9e45e0c9b6 docs: document outbound text chunking 2026-06-01 12:13:47 -04:00
Peter Steinberger
c9f51ad18d docs: document channel route identity helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
a5b42a7500 docs: document inbound debounce policy 2026-06-01 12:13:47 -04:00
Peter Steinberger
0cff3edb56 docs: document channel config matching helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
fb756242be docs: document plugin host cleanup timeout 2026-06-01 12:13:47 -04:00
Peter Steinberger
fe384065fe docs: document provider catalog helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
a8bbff2f9e docs: document channel model override resolution 2026-06-01 12:13:47 -04:00
Peter Steinberger
cceb080869 docs: document channel config helper contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
e87e873017 docs: document account snapshot field projection 2026-06-01 12:13:47 -04:00
Peter Steinberger
7764e91417 docs: document thread binding policy helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
535616c292 docs: document channel target parsing contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
62eb7259c3 docs: document direct dm access bridge 2026-06-01 12:13:47 -04:00
Peter Steinberger
e5d7cf2efc docs: document provider catalog text projection 2026-06-01 12:13:47 -04:00
Peter Steinberger
ed43f9090d docs: document gateway startup plugin scopes 2026-06-01 12:13:47 -04:00
Peter Steinberger
e634c7459e docs: document embedding provider runtime lookup 2026-06-01 12:13:47 -04:00
Peter Steinberger
2ad26392c5 docs: document memory embedding provider runtime 2026-06-01 12:13:47 -04:00
Peter Steinberger
378146c9bc docs: document memory runtime helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
d67ff3e041 docs: document bundle config helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
db2df9dd79 docs: document plugin config state 2026-06-01 12:13:47 -04:00
Peter Steinberger
d43ba91710 docs: document plugin activation context 2026-06-01 12:13:47 -04:00
Peter Steinberger
d6145ad4c2 docs: document trusted tool policy flow 2026-06-01 12:13:47 -04:00
Peter Steinberger
0c3b71ba23 docs: document provider install catalog 2026-06-01 12:13:47 -04:00
Peter Steinberger
d58a649a33 docs: document web search provider helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
c41b710ae9 docs: document web fetch provider helpers 2026-06-01 12:13:47 -04:00
Peter Steinberger
8c3e7eddfd docs: document web provider resolution 2026-06-01 12:13:47 -04:00
Peter Steinberger
09d8eae1e2 docs: document manifest model suppression 2026-06-01 12:13:47 -04:00
Peter Steinberger
786d5c1042 docs: document manifest owner policy 2026-06-01 12:13:47 -04:00
Peter Steinberger
fd88ce0039 docs: document manifest tool availability 2026-06-01 12:13:47 -04:00
Peter Steinberger
b487a2dfbb docs: document plugin scheduled turn contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
1802ed180a docs: document uninstall helper contracts 2026-06-01 12:13:47 -04:00
Peter Steinberger
0c790251e1 docs: document effective plugin id resolution 2026-06-01 12:13:47 -04:00
Peter Steinberger
52ad1b26ef docs: document optional plugin manifest rule 2026-06-01 12:13:46 -04:00
Peter Steinberger
88cefa4d3f docs: document web search credential detection 2026-06-01 12:13:46 -04:00
Peter Steinberger
ef6e5aa961 docs: document hook agent channel context 2026-06-01 12:13:46 -04:00
Peter Steinberger
2c04aea604 docs: document host hook cleanup timeout 2026-06-01 12:13:46 -04:00
Peter Steinberger
1cb93fee3e docs: document agent tool result middleware helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
247a67320a docs: document plugin source display 2026-06-01 12:13:46 -04:00
Peter Steinberger
02aee615de docs: document plugin control plane context 2026-06-01 12:13:46 -04:00
Peter Steinberger
ba7e68b271 docs: document plugin scope helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
f3b723fd9a docs: document provider auth input mode 2026-06-01 12:13:46 -04:00
Peter Steinberger
58a5c1e512 docs: document installed plugin index store paths 2026-06-01 12:13:46 -04:00
Peter Steinberger
43212e574c docs: document plugin HTTP path normalization 2026-06-01 12:13:46 -04:00
Peter Steinberger
95bd60001d docs: document provider config owner hints 2026-06-01 12:13:46 -04:00
Peter Steinberger
ac206252fa docs: document plugin conversation binding runtime 2026-06-01 12:13:46 -04:00
Peter Steinberger
7ad843234f docs: document interactive registry helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
e52c366a07 docs: document plugin tool descriptor cache 2026-06-01 12:13:46 -04:00
Peter Steinberger
681a0863f1 docs: document plugin cache primitives 2026-06-01 12:13:46 -04:00
Peter Steinberger
ee23d27ce2 docs: document plugin root cache inputs 2026-06-01 12:13:46 -04:00
Peter Steinberger
175db3e84d docs: document package entrypoint candidates 2026-06-01 12:13:46 -04:00
Peter Steinberger
07693abbca docs: document bundled public surface resolution 2026-06-01 12:13:46 -04:00
Peter Steinberger
27359abe70 docs: document host hook JSON boundary 2026-06-01 12:13:46 -04:00
Peter Steinberger
41ea42f864 docs: document setup wizard flow 2026-06-01 12:13:46 -04:00
Peter Steinberger
30f3fd75b1 docs: clarify configured binding matching 2026-06-01 12:13:46 -04:00
Peter Steinberger
4518c7f673 docs: document stateful target driver contract 2026-06-01 12:13:46 -04:00
Peter Steinberger
39b93679b5 docs: document configured binding consumer contract 2026-06-01 12:13:46 -04:00
Peter Steinberger
8eb8eef88e docs: document configured binding skips 2026-06-01 12:13:46 -04:00
Peter Steinberger
8b02c78f46 docs: document chat target prefix helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
a181224d0a docs: document status approval utilities 2026-06-01 12:13:46 -04:00
Peter Steinberger
ef8f96aeca docs: document channel module loader 2026-06-01 12:13:46 -04:00
Peter Steinberger
5f143b6361 docs: document config write ambiguity 2026-06-01 12:13:46 -04:00
Peter Steinberger
db4fb64e2f docs: document group policy warning helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
23426e4d26 docs: document channel utility entrypoints 2026-06-01 12:13:46 -04:00
Peter Steinberger
6fd7ffd4c4 docs: document status issue helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
962cae0bf9 docs: document setup wizard type contracts 2026-06-01 12:13:46 -04:00
Peter Steinberger
c2364779e0 docs: document channel adapter type contracts 2026-06-01 12:13:46 -04:00
Peter Steinberger
a17b95e2dc docs: document channel plugin type contracts 2026-06-01 12:13:46 -04:00
Peter Steinberger
2987e9bc82 docs: document outbound config types 2026-06-01 12:13:46 -04:00
Peter Steinberger
9011a31d56 docs: document setup wizard helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
55f124ed01 docs: document setup promotion helpers 2026-06-01 12:13:46 -04:00
Peter Steinberger
2a42a0e2fe docs: document bundled read helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
6a8090b7d8 docs: document approval config schema helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
88c1abb9b5 docs: document dm pairing helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
a9f3e35813 docs: document channel runtime helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
39b3364ae5 docs: document media helper APIs 2026-06-01 12:13:45 -04:00
Peter Steinberger
a9176b3e3c docs: document threading target helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
df4512571f docs: document channel state helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
ba95ba46da docs: document channel catalog helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
30678b2812 docs: document bundled channel loader 2026-06-01 12:13:45 -04:00
Peter Steinberger
6477e3c75a docs: document channel registry helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
69763d0d0e docs: document outbound loader APIs 2026-06-01 12:13:45 -04:00
Peter Steinberger
bd3683052d docs: document presentation limit invariants 2026-06-01 12:13:45 -04:00
Peter Steinberger
061cddc829 docs: document outbound helper APIs 2026-06-01 12:13:45 -04:00
Peter Steinberger
112a78b070 docs: document message action helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
c665944276 docs: document message tool APIs 2026-06-01 12:13:45 -04:00
Peter Steinberger
fd883d2eb4 docs: document message action discovery 2026-06-01 12:13:45 -04:00
Peter Steinberger
69583e9f15 docs: document stateful target builtins 2026-06-01 12:13:45 -04:00
Peter Steinberger
6234092a66 docs: document configured binding consumers 2026-06-01 12:13:45 -04:00
Peter Steinberger
d69a72f98e docs: document binding public APIs 2026-06-01 12:13:45 -04:00
Peter Steinberger
bfaaac79b6 docs: document stateful target drivers 2026-06-01 12:13:45 -04:00
Peter Steinberger
bc36755609 docs: document configured binding registry 2026-06-01 12:13:45 -04:00
Peter Steinberger
a1223825a2 docs: document binding routing helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
c05687aa34 docs: document directory adapter helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
d032288a77 docs: document directory config helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
7e71a0b4a4 docs: document setup wizard prompt helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
187dd18674 docs: document setup wizard policy helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
f2e6163788 docs: document setup wizard helper parsing 2026-06-01 12:13:45 -04:00
Peter Steinberger
f54ee04c05 docs: document setup wizard delegation 2026-06-01 12:13:45 -04:00
Peter Steinberger
d2b8293236 docs: document setup promotion keys 2026-06-01 12:13:45 -04:00
Peter Steinberger
10c99178c6 docs: document setup promotion helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
9fdc022ad0 docs: document plugin config helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
bbbb3ad27b docs: document config write wrappers 2026-06-01 12:13:45 -04:00
Peter Steinberger
824abf5fa1 docs: document config write policy helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
3de4e9e00f docs: document channel account helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
bda364fb74 docs: document account action gate 2026-06-01 12:13:45 -04:00
Peter Steinberger
0785082b8d docs: document target resolver helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
80dd8c390e docs: document channel route helper invariants 2026-06-01 12:13:45 -04:00
Peter Steinberger
df637ed2f8 docs: document channel target parser contracts 2026-06-01 12:13:45 -04:00
Peter Steinberger
69b1b3fdd3 docs: document chat target prefix helpers 2026-06-01 12:13:45 -04:00
Peter Steinberger
6ae61ffaef docs: document allowlist matcher invariants 2026-06-01 12:13:45 -04:00
Peter Steinberger
8b9b4ce082 docs: document allowlist resolution helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
75223a869d docs: document plugin allow-from helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
e3514e8d71 docs: document channel gating helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
4088a58674 docs: document channel ingress sender gates 2026-06-01 12:13:44 -04:00
Peter Steinberger
ae3f41f6c3 docs: document channel ingress allowlist helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
52e1d14e94 docs: document channel ingress identity helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
0b41911c70 docs: document channel ingress runtime invariants 2026-06-01 12:13:44 -04:00
Peter Steinberger
c3fa7f2148 docs: document channel turn helper invariants 2026-06-01 12:13:44 -04:00
Peter Steinberger
e20d87cfc3 docs: document delivery result compatibility 2026-06-01 12:13:44 -04:00
Peter Steinberger
fb94dac19d docs: document durable reply delivery 2026-06-01 12:13:44 -04:00
Peter Steinberger
c959f82d5c docs: document channel turn kernel invariants 2026-06-01 12:13:44 -04:00
Peter Steinberger
8502427352 docs: document inbound reply dispatch delivery 2026-06-01 12:13:44 -04:00
Peter Steinberger
853e32fef3 docs: document reply prefix context 2026-06-01 12:13:44 -04:00
Peter Steinberger
bbee5e456c docs: document channel typing internals 2026-06-01 12:13:44 -04:00
Peter Steinberger
e4e3a8dbc4 docs: document channel typing lifecycle 2026-06-01 12:13:44 -04:00
Peter Steinberger
596ee3c2a8 docs: document channel reply pipeline contracts 2026-06-01 12:13:44 -04:00
Peter Steinberger
e836cd8b71 docs: document channel inbound compatibility 2026-06-01 12:13:44 -04:00
Peter Steinberger
3f313b0ca9 docs: document channel config helper contracts 2026-06-01 12:13:44 -04:00
Peter Steinberger
7759b44638 docs: document channel policy helper contracts 2026-06-01 12:13:44 -04:00
Peter Steinberger
fa3e1067a6 docs: document channel send result helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
95eaf32b61 docs: document channel route helper contracts 2026-06-01 12:13:44 -04:00
Peter Steinberger
902c2d685c docs: document status helper contracts 2026-06-01 12:13:44 -04:00
Peter Steinberger
e5d1ce4f84 docs: document webhook target helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
1f6058f495 docs: document webhook memory guards 2026-06-01 12:13:44 -04:00
Peter Steinberger
95a84d98e4 docs: document webhook request guards 2026-06-01 12:13:44 -04:00
Peter Steinberger
3534f68068 docs: document plugin runtime store 2026-06-01 12:13:44 -04:00
Peter Steinberger
9d71225d39 docs: document agent harness task runtime 2026-06-01 12:13:44 -04:00
Peter Steinberger
02043fe89b docs: document agent harness task runtime scope 2026-06-01 12:13:44 -04:00
Peter Steinberger
32abf56791 docs: document detached task runtime state 2026-06-01 12:13:44 -04:00
Peter Steinberger
750bbdf09f docs: document detached task runtime dispatch 2026-06-01 12:13:44 -04:00
Peter Steinberger
afe95da1f7 docs: document detached task runtime contract 2026-06-01 12:13:44 -04:00
Peter Steinberger
da42fb0a81 docs: document task registry types 2026-06-01 12:13:44 -04:00
Peter Steinberger
ba65ce48a0 docs: document task registry summaries 2026-06-01 12:13:44 -04:00
Peter Steinberger
d7a35e7079 docs: document task domain view mappers 2026-06-01 12:13:44 -04:00
Peter Steinberger
8016ce9999 docs: document task status helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
1f9a80ca61 docs: document required completion contract 2026-06-01 12:13:44 -04:00
Peter Steinberger
a21a7ee883 docs: document task executor policy helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
1d0f43a709 docs: document blocked taskflow retry helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
f1ecfbe08f docs: document taskflow executor helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
301c84204d docs: document task owner access helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
ca78f99c96 docs: document taskflow owner access helpers 2026-06-01 12:13:44 -04:00
Peter Steinberger
1d4f70a8cd docs: document managed taskflow runtime guards 2026-06-01 12:13:44 -04:00
Peter Steinberger
a8113c72f6 docs: document managed taskflow runtime types 2026-06-01 12:13:43 -04:00
Peter Steinberger
4f48cd1413 docs: document plugin runtime task DTOs 2026-06-01 12:13:43 -04:00
Peter Steinberger
843dfafaa8 docs: document plugin runtime task types 2026-06-01 12:13:43 -04:00
Peter Steinberger
0db557a6dc docs: document plugin runtime llm types 2026-06-01 12:13:43 -04:00
Peter Steinberger
d598a239ca docs: document plugin runtime core types 2026-06-01 12:13:43 -04:00
Peter Steinberger
037cf3ed86 docs: document plugin runtime types 2026-06-01 12:13:43 -04:00
Peter Steinberger
89203a47dd docs: document runtime context registry 2026-06-01 12:13:43 -04:00
Peter Steinberger
0160c650e6 docs: document channel runtime surface types 2026-06-01 12:13:43 -04:00
Peter Steinberger
d92e91373c docs: document channel runtime contexts 2026-06-01 12:13:43 -04:00
Peter Steinberger
0f4eedd32a docs: document approval handler bootstrap 2026-06-01 12:13:43 -04:00
Peter Steinberger
a1a836f2bb docs: document exec approval channel runtime 2026-06-01 12:13:43 -04:00
Peter Steinberger
c4a8e1be9b docs: document exec approval runtime types 2026-06-01 12:13:43 -04:00
Peter Steinberger
7a070e6ca2 docs: document approval native runtime helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
904f84df05 docs: document approval route coordinator 2026-06-01 12:13:43 -04:00
Peter Steinberger
fbb050028d docs: document exec approval surface states 2026-06-01 12:13:43 -04:00
Peter Steinberger
fb78550cbb docs: document approval request filters 2026-06-01 12:13:43 -04:00
Peter Steinberger
96e9d73a64 docs: document exec approval display sanitizers 2026-06-01 12:13:43 -04:00
Peter Steinberger
365b63de19 docs: document exec approval session targets 2026-06-01 12:13:43 -04:00
Peter Steinberger
410bf91087 docs: document approval account binding helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
4d9d9d3e42 docs: document approval gateway resolver 2026-06-01 12:13:43 -04:00
Peter Steinberger
c1d56cb9b3 docs: document approval error detection 2026-06-01 12:13:43 -04:00
Peter Steinberger
6b8fd7a3cd docs: document approval turn-source routing 2026-06-01 12:13:43 -04:00
Peter Steinberger
5f9926b7fd docs: document approval view model builders 2026-06-01 12:13:43 -04:00
Peter Steinberger
4becd8dbfe docs: document approval view model unions 2026-06-01 12:13:43 -04:00
Peter Steinberger
a8a2be4f33 docs: document approval handler adapters 2026-06-01 12:13:43 -04:00
Peter Steinberger
d688f72752 docs: document lazy approval runtime adapter 2026-06-01 12:13:43 -04:00
Peter Steinberger
19d0073e5f docs: document approval handler runtime types 2026-06-01 12:13:43 -04:00
Peter Steinberger
74eacd9742 docs: document approval native runtime types 2026-06-01 12:13:43 -04:00
Peter Steinberger
22518f9820 docs: document approval native target keys 2026-06-01 12:13:43 -04:00
Peter Steinberger
3f04d320ad docs: document approval native delivery planner 2026-06-01 12:13:43 -04:00
Peter Steinberger
31420c16e1 docs: document approval native route notices 2026-06-01 12:13:43 -04:00
Peter Steinberger
4276ba3b60 docs: document approval display path helper 2026-06-01 12:13:43 -04:00
Peter Steinberger
a7b2cd5be2 docs: document exec approval surface helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
1cf7ea66e5 docs: document safe-bin runtime policy 2026-06-01 12:13:43 -04:00
Peter Steinberger
97026eab56 docs: document safe-bin argv validator 2026-06-01 12:13:43 -04:00
Peter Steinberger
6fa4e7ceb0 docs: document safe-bin semantic helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
1fe2d34e01 docs: document safe-bin profile helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
2751480168 docs: document safe-bin trust helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
930b1fc082 docs: document exec allowlist pattern matching 2026-06-01 12:13:43 -04:00
Peter Steinberger
ba9825795b docs: document shell wrapper APIs 2026-06-01 12:13:43 -04:00
Peter Steinberger
3f5bf3ac35 docs: document executable path helpers 2026-06-01 12:13:43 -04:00
Peter Steinberger
8197cdcac4 docs: document exec command resolution APIs 2026-06-01 12:13:43 -04:00
Peter Steinberger
38306a7695 docs: document exec command analysis APIs 2026-06-01 12:13:43 -04:00
Peter Steinberger
fd7b7a09d8 docs: document exec allowlist result APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
1752e50eb1 docs: document system-run approval match result 2026-06-01 12:13:42 -04:00
Peter Steinberger
92138702fb docs: document exec approval request config APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
934bf883c1 docs: document exec approval prompt policy 2026-06-01 12:13:42 -04:00
Peter Steinberger
42b0b53efa docs: document exec approval allowlist APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
6d478c61cf docs: document exec approval store APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
4a48b7efe7 docs: document exec approval decision APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
b51b9cbbf4 docs: document dispatch wrapper resolution APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
fa568259e4 docs: document exec approval reply APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
1e26fa770d docs: document dotenv loader contracts 2026-06-01 12:13:42 -04:00
Peter Steinberger
3693916c0c docs: document update channel contracts 2026-06-01 12:13:42 -04:00
Peter Steinberger
5b313c819a docs: document home directory helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
cafea5c3ef docs: document exec approval policy combinators 2026-06-01 12:13:42 -04:00
Peter Steinberger
ab6bc8d109 docs: document exec approval policy APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
db0470aece docs: document error kind contract 2026-06-01 12:13:42 -04:00
Peter Steinberger
cf49d56b74 docs: document exec approval session target 2026-06-01 12:13:42 -04:00
Peter Steinberger
2eeabc4e12 docs: document exec approval surface APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
f0af33a0ff docs: document event session routing APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
20133a58a9 docs: document approval request filters 2026-06-01 12:13:42 -04:00
Peter Steinberger
2a69d62245 docs: document container environment helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
14440032bd docs: document clipboard helper 2026-06-01 12:13:42 -04:00
Peter Steinberger
ea516f648b docs: document clawhub spec parser 2026-06-01 12:13:42 -04:00
Peter Steinberger
b71792767e docs: document channel summary APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
7939c408cf docs: document channel runtime context APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
d017bacc5a docs: document bonjour discovery APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
86ad6d9772 docs: document diagnostic event APIs 2026-06-01 12:13:42 -04:00
Peter Steinberger
0dcb3ce86b docs: document channel activity helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
a864715dd0 docs: document agent event contracts 2026-06-01 12:13:42 -04:00
Peter Steinberger
474cdce26c docs: document source reply mirror 2026-06-01 12:13:42 -04:00
Peter Steinberger
5917d8ba45 docs: document outbound session context 2026-06-01 12:13:42 -04:00
Peter Steinberger
1bc4ba9908 docs: document outbound delivery substrate 2026-06-01 12:13:42 -04:00
Peter Steinberger
6f16ee9266 docs: document session binding service 2026-06-01 12:13:42 -04:00
Peter Steinberger
2c7c7bf7f9 docs: document delivery queue storage 2026-06-01 12:13:42 -04:00
Peter Steinberger
7580daf705 docs: document delivery recovery helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
e6c50fd771 docs: document outbound message API 2026-06-01 12:13:42 -04:00
Peter Steinberger
f3aae8a380 docs: document message action runner API 2026-06-01 12:13:42 -04:00
Peter Steinberger
34c5d059aa docs: document agent delivery helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
eab3b1a6a2 docs: document npm install env helpers 2026-06-01 12:13:42 -04:00
Peter Steinberger
0edb913c13 docs: document OpenClaw exec env marker 2026-06-01 12:13:41 -04:00
Peter Steinberger
74d98e1fd7 docs: document shell env fallback 2026-06-01 12:13:41 -04:00
Peter Steinberger
082c443015 docs: document binary prerequisite helper 2026-06-01 12:13:41 -04:00
Peter Steinberger
8340b1151c docs: document control UI asset helpers 2026-06-01 12:13:41 -04:00
Peter Steinberger
89daadd478 docs: document channel status issues 2026-06-01 12:13:41 -04:00
Peter Steinberger
3d335e402a docs: document brew resolution 2026-06-01 12:13:41 -04:00
Peter Steinberger
8b5a6bda51 docs: document CLI root option parsing 2026-06-01 12:13:41 -04:00
Peter Steinberger
3ac62666ed docs: document diagnostics timeline 2026-06-01 12:13:41 -04:00
Peter Steinberger
8d5a2f5fa9 docs: document diagnostic LLM content policy 2026-06-01 12:13:41 -04:00
Peter Steinberger
0b5ead9f37 docs: document diagnostic trace context 2026-06-01 12:13:41 -04:00
Peter Steinberger
67e6f9aaba docs: document disk space helpers 2026-06-01 12:13:41 -04:00
Peter Steinberger
983c5a664c docs: document diagnostic flags 2026-06-01 12:13:41 -04:00
Peter Steinberger
b5ee774d68 docs: document embedded mode flag 2026-06-01 12:13:41 -04:00
Peter Steinberger
9676536668 docs: document package manager detection 2026-06-01 12:13:41 -04:00
Peter Steinberger
441a7cf792 docs: document browser open helpers 2026-06-01 12:13:41 -04:00
Peter Steinberger
3a35c1e806 docs: document map size pruning 2026-06-01 12:13:41 -04:00
Peter Steinberger
fbeaf41dc2 docs: document abort signal helper 2026-06-01 12:13:41 -04:00
Peter Steinberger
5590a45e7e docs: document channel activity 2026-06-01 12:13:41 -04:00
Peter Steinberger
9a551d49f3 docs: document approval gateway runtime 2026-06-01 12:13:41 -04:00
Peter Steinberger
fdae22dfea docs: document approval turn source auth 2026-06-01 12:13:41 -04:00
Peter Steinberger
552fa03822 docs: document approval display errors 2026-06-01 12:13:41 -04:00
Peter Steinberger
7e97b42a95 docs: document approval view model 2026-06-01 12:13:41 -04:00
Peter Steinberger
d3b9c5aa3e docs: document approval handler runtime 2026-06-01 12:13:41 -04:00
Peter Steinberger
78172b720b docs: document native approval runtime 2026-06-01 12:13:41 -04:00
Peter Steinberger
9ea00cf73a docs: document native approval delivery 2026-06-01 12:13:41 -04:00
Peter Steinberger
a5013c5574 docs: document native approval route notices 2026-06-01 12:13:41 -04:00
Peter Steinberger
ec7ae4fc9a docs: document approval request binding filters 2026-06-01 12:13:41 -04:00
Peter Steinberger
a9e6e4c5e3 docs: document exec approval channel runtime 2026-06-01 12:13:41 -04:00
Peter Steinberger
92e6368860 docs: document exec approval forwarder 2026-06-01 12:13:41 -04:00
Peter Steinberger
6757a52944 docs: document exec approval reply routing 2026-06-01 12:13:41 -04:00
Peter Steinberger
e011559750 docs: document system-run approval binding 2026-06-01 12:13:41 -04:00
Peter Steinberger
4d63f1ea8c docs: document system-run approval context 2026-06-01 12:13:41 -04:00
Peter Steinberger
36d1080d83 docs: document system-run command contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
d3e8a89959 docs: document exec allowlist entry contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
8b3c5d898a docs: document exec allowlist contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
0c5b962a29 docs: document safe-bin profile contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
1a43a00def docs: document safe-bin trust contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
439904eef4 docs: document safe-bin runtime policy contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
9bd5808fda docs: document exec approval policy snapshots 2026-06-01 12:13:41 -04:00
Peter Steinberger
32bf8712e9 docs: document approval display contracts 2026-06-01 12:13:41 -04:00
Peter Steinberger
2466798a08 docs: document executable path contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
797e503fe8 docs: document exec command resolution contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
c7e238a862 docs: document shell inline command scanners 2026-06-01 12:13:40 -04:00
Peter Steinberger
0097a6fb46 docs: document shell wrapper trust contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
8a3bda61e1 docs: document dispatch wrapper trust contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
fd715e0eee docs: document command carrier helper contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
f83ff78bb8 docs: document command explainer span contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
9094429658 docs: document command policy analysis contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
909d521602 docs: document command explanation summary contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
44cef2a792 docs: document command risk carrier contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
0d4dec734d docs: document inline eval detector contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
41aee0429c docs: document TCP port parser contract 2026-06-01 12:13:40 -04:00
Peter Steinberger
e1f1045d46 docs: document port diagnostics contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
a98b9ceb37 docs: document package tag input contract 2026-06-01 12:13:40 -04:00
Peter Steinberger
b6064d1cf5 docs: document update channel contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
fbbf2e6237 docs: document inline option token contract 2026-06-01 12:13:40 -04:00
Peter Steinberger
2f2c77e192 docs: document prototype key guard contract 2026-06-01 12:13:40 -04:00
Peter Steinberger
d64c80daae docs: document environment helper contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
db9ced7b9d docs: document PATH bootstrap contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
11576303ab docs: document fetch header normalization contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
c2f5594555 docs: document secret file compatibility contract 2026-06-01 12:13:40 -04:00
Peter Steinberger
df1e4177e4 docs: document JSON file helper contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
a7d11dd3c7 docs: document HTTP body guard contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
493e4ab2f9 docs: document fixed-window rate limiter contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
0f4fa29d78 docs: document backoff helper contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
f25cbad91b docs: document retry engine contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
713d4cd355 docs: document retry policy contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
2ed5feffef docs: document number coercion thresholds 2026-06-01 12:13:40 -04:00
Peter Steinberger
99d6f0f8c1 docs: document map and numeric option helpers 2026-06-01 12:13:40 -04:00
Peter Steinberger
dcbe7e30d9 docs: document dedupe cache contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
917d24f5c9 docs: document diagnostic error metadata helpers 2026-06-01 12:13:40 -04:00
Peter Steinberger
be922af1e6 docs: document shared error helpers 2026-06-01 12:13:40 -04:00
Peter Steinberger
bc3165647f docs: document reasoning tag partitioner contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
9f36c0f00c docs: document trajectory path helpers 2026-06-01 12:13:40 -04:00
Peter Steinberger
35ee75ec6b docs: document trajectory runtime writer contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
6a14ad3189 docs: document trajectory cleanup guards 2026-06-01 12:13:40 -04:00
Peter Steinberger
3451c03366 docs: document trajectory export contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
5b3d73bc90 docs: document trajectory metadata contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
97620910ef docs: document support bundle writer contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
e58cb30c44 docs: document diagnostic support export contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
158dd20e24 docs: document support log sanitizer invariants 2026-06-01 12:13:40 -04:00
Peter Steinberger
16d872f02d docs: document support redaction contracts 2026-06-01 12:13:40 -04:00
Peter Steinberger
3edc65d397 docs: document logging redaction API contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
86260867ad docs: document bounded concurrency helper contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
dd062c655c docs: document fetch timeout abort contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
b8244deddb docs: document gateway client public contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
b1fa7f0e16 docs: document device auth payload contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
eed3735edd docs: document gateway client readiness start rules 2026-06-01 12:13:39 -04:00
Peter Steinberger
9d1edb4c00 docs: document event loop readiness probe fields 2026-06-01 12:13:39 -04:00
Peter Steinberger
d39c2051d0 docs: document gateway timeout clamp rules 2026-06-01 12:13:39 -04:00
Peter Steinberger
d008a425c2 docs: document live plugin config fallback 2026-06-01 12:13:39 -04:00
Peter Steinberger
7fc4dd9d14 docs: document node presence reasons 2026-06-01 12:13:39 -04:00
Peter Steinberger
c3697c2ac1 docs: document avatar policy constants 2026-06-01 12:13:39 -04:00
Peter Steinberger
de85fcd978 docs: document OpenAI Codex auth helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
8fb987c565 docs: document plugin command runner 2026-06-01 12:13:39 -04:00
Peter Steinberger
8cacdce95e docs: document tool send target fallback 2026-06-01 12:13:39 -04:00
Peter Steinberger
fd12d434ba docs: document telegram account facade 2026-06-01 12:13:39 -04:00
Peter Steinberger
a70b17e5cb docs: document runtime store helper 2026-06-01 12:13:39 -04:00
Peter Steinberger
283dff0c19 docs: document webhook request guard helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
e2878dcf33 docs: document keyed async queue helper 2026-06-01 12:13:39 -04:00
Peter Steinberger
af9f15074f docs: document tool payload helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
e7e7e4f2f1 docs: document plugin SDK runtime helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
0ba732cf5e docs: document browser maintenance helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
a9120d2df6 docs: document safe record helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
d3106d2209 docs: document approval reaction helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
e174ddaaeb docs: document provider catalog helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
8b3a9a5617 docs: document provider onboard helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
c6fed61806 docs: document allowlist config helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
602a8e2d10 docs: document provider tool compat helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
1c31afac81 docs: document persistent dedupe helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
5ceb45d38e docs: document account setup helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
ff326e9ca5 docs: document auth and chunk helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
2991ae6fc9 docs: document assistant text helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
4f12fa1d70 docs: document command status runtime 2026-06-01 12:13:39 -04:00
Peter Steinberger
a73f42096e docs: document provider model helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
c11a3a0d78 docs: document browser config helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
c4520714c8 docs: document QA scenario helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
157e893b77 docs: document migration helper contracts 2026-06-01 12:13:39 -04:00
Peter Steinberger
f260f1bc06 docs: document status helper payloads 2026-06-01 12:13:39 -04:00
Peter Steinberger
8cba61f985 docs: document extension shared helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
48f2eef53b docs: document session policy payloads 2026-06-01 12:13:39 -04:00
Peter Steinberger
692dbb7b3f docs: document webhook guard helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
fc2e6ab07e docs: document channel diagnostics helpers 2026-06-01 12:13:39 -04:00
Peter Steinberger
00465096ce docs: document thread binding label helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
7ee37a45c4 docs: document reply prefix typing guard helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
102f1427e9 docs: document inbound debounce stream helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
bf14891ff3 docs: document direct dm allow-from helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
fc84fd8f26 docs: document channel match allowlist helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
6a10a55114 docs: document channel snapshot presence helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
6e1e89cbe9 docs: document channel config helper contracts 2026-06-01 12:13:38 -04:00
Peter Steinberger
df725c5b4e docs: document channel turn adapter contracts 2026-06-01 12:13:38 -04:00
Peter Steinberger
0c7f9ea6be docs: document channel turn delivery helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
216a2daf23 docs: document inbound reply bridge helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
a48823f18b docs: document message receive capability helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
c4d88ffc3e docs: document durable ingress queue helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
02e12555bb docs: document message outbound bridge helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
9888974144 docs: document durable message state fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
c78717b229 docs: document live message contracts 2026-06-01 12:13:38 -04:00
Peter Steinberger
d5d0090865 docs: document message receipt fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
f407e71101 docs: document channel turn helper fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
5d713e20ec docs: document inbound event context helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
c291eb6c6c docs: document conversation resolution helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
36c53e66ef docs: document route projection helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
6810dfd575 docs: document direct dm guard policy 2026-06-01 12:13:38 -04:00
Peter Steinberger
9b40fcd056 docs: document ack reaction helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
f529df5b97 docs: document channel target policy helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
f9c86a65a6 docs: document thread binding policy fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
e47f45e322 docs: document command gating fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
7ef4d676c9 docs: document mention gating fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
0c2dc54eae docs: document entry status fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
b21b889017 docs: document usage timeseries fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
6991205bd8 docs: document usage payload fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
dfdcd2aa97 docs: document node list payload fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
dc0cc1b7c1 docs: document thread binding lifecycle fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
25ce9fbb31 docs: document runtime requirement fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
ca6fd41b95 docs: document requirement metadata fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
6a6930983b docs: document store writer queue fields 2026-06-01 12:13:38 -04:00
Peter Steinberger
a1ff03b634 docs: document scoped id cache helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
4d8686c24e docs: document custom command config helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
5802610280 docs: document final tag parser helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
51c0ca2aa6 docs: document device auth store helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
f3a313bfd1 docs: document node matching helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
afa810271a docs: document shared runtime policy helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
11d9b2780b docs: document shared utility helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
4a2ce15e59 docs: document assistant error format helpers 2026-06-01 12:13:38 -04:00
Peter Steinberger
c1e7449b28 docs: document shared json schema helpers 2026-06-01 12:13:37 -04:00
Peter Steinberger
49156048f0 docs: document markdown table chunk helpers 2026-06-01 12:13:37 -04:00
Peter Steinberger
1c212ee73f docs: document markdown ir helpers 2026-06-01 12:13:37 -04:00
Peter Steinberger
63625093a1 docs: document markdown code span helpers 2026-06-01 12:13:37 -04:00
Peter Steinberger
fb59ac217c docs: document markdown render helpers 2026-06-01 12:13:37 -04:00
Vincent Koc
c053b90290 refactor: share shared auth rotation test helpers 2026-06-01 18:10:57 +02:00
Pavan Kumar Gondhi
fbdf593778 fix: bound remote media reference reads [AI] (#88974)
* fix: bound remote media reference reads

* fix: remove unreachable video timeout wiring

* test: cover remote video reference handoff
2026-06-01 21:35:40 +05:30
Vincent Koc
488b65ab87 refactor: share session reset test helpers 2026-06-01 18:00:39 +02:00
Peter Steinberger
6668eb8225 test(codex): drop unused live harness helper 2026-06-01 16:56:48 +01:00
Vincent Koc
72436217ff fix(e2e): isolate MCP channel client temp state 2026-06-01 17:51:04 +02:00
Peter Steinberger
460cf7ed75 test(codex): avoid sessions list wait in live harness start probe 2026-06-01 16:49:20 +01:00
Vincent Koc
461999c060 fix(dev): clean Telegram flow previews on failure 2026-06-01 17:37:15 +02:00
Vincent Koc
9cb347e4c3 fix(dev): close gateway smoke websocket on failures 2026-06-01 17:26:15 +02:00
Vincent Koc
1d7e5f48ed fix(dev): close stalled gateway websocket handshakes 2026-06-01 17:18:40 +02:00
Vincent Koc
1fd2259e28 refactor: share config patch test helpers 2026-06-01 17:15:48 +02:00
Peter Steinberger
3f54d150b3 test(openrouter): stabilize music timeout clamp assertion 2026-06-01 16:09:23 +01:00
Vincent Koc
a9866a405c test(agents): align provider auth alias fixtures 2026-06-01 17:08:31 +02:00
Vincent Koc
0b9187c780 test(gateway): fix node invoke capture race 2026-06-01 17:08:31 +02:00
Vincent Koc
b1ec23e05f fix(e2e): escalate stuck PTY children 2026-06-01 17:07:42 +02:00
Vincent Koc
050f0c0af6 refactor: share device pair authz test helpers 2026-06-01 16:58:10 +02:00
Vincent Koc
dfeb5b81ca fix(e2e): harden Parallels helper cleanup 2026-06-01 16:57:27 +02:00
Vincent Koc
d9f6e03e32 refactor: share silent reconnect test helpers 2026-06-01 16:54:10 +02:00
Peter Steinberger
fed7d1f385 test(release): stabilize beta validation regressions 2026-06-01 15:47:56 +01:00
Vincent Koc
0a9e594420 fix(scripts): clean Anthropic prompt probe temp state 2026-06-01 16:47:27 +02:00
Sally O'Malley
c1ce51546e fix(ui): clear chat composer after send (#89106) 2026-06-01 10:42:35 -04:00
Vincent Koc
1b928592ef refactor: share startup recovery test helpers 2026-06-01 16:37:09 +02:00
Vincent Koc
12087ac9d4 test(e2e): exercise Parallels smoke cleanup path 2026-06-01 16:33:11 +02:00
Peter Steinberger
00caead80a test: close oxlint signal readiness race 2026-06-01 10:26:08 -04:00
Peter Steinberger
4b54a423f0 test: harden changed-gate assertions 2026-06-01 10:26:08 -04:00
Peter Steinberger
bdd6cf3d5e test: stabilize order-sensitive assertions 2026-06-01 10:26:08 -04:00
Peter Steinberger
cb7a4239ef fix: stabilize full-suite regressions 2026-06-01 10:26:08 -04:00
Peter Steinberger
b226a752a1 test: stabilize slow shard regressions 2026-06-01 10:26:08 -04:00
Vincent Koc
110f7d55e3 fix(scripts): clean Z.AI fallback repro temp state 2026-06-01 16:25:05 +02:00
Vincent Koc
645c7dc40b refactor: share gateway misc test helpers 2026-06-01 16:18:22 +02:00
Vincent Koc
a4847297b8 fix(ci): clean check-changed pnpm shim temp dirs 2026-06-01 16:16:26 +02:00
Vincent Koc
4253517070 refactor: share node allowlist test helpers 2026-06-01 16:14:59 +02:00
Peter Steinberger
e8c126eaf2 fix(ci): use QA runtime build for release checks 2026-06-01 15:12:50 +01:00
Peter Steinberger
2075d19923 test(gateway): scope lazy server mock 2026-06-01 15:12:50 +01:00
Vincent Koc
9e58ef1c82 test(scripts): clean session log temp roots 2026-06-01 16:00:41 +02:00
Vincent Koc
eaeccf5fdf refactor: share node registry system run test helpers 2026-06-01 16:00:36 +02:00
Vincent Koc
2c0e835b48 test(codex): clean up fake timer spies 2026-06-01 14:57:47 +01:00
Vincent Koc
b942a958b3 test(qa): cover QA lab help runtime boundary 2026-06-01 15:54:16 +02:00
Vincent Koc
42bcf9cd0b fix(test): keep runtime tests raw-sync safe 2026-06-01 15:53:37 +02:00
Vincent Koc
a0fbb6cfe2 fix(test): keep app parity checks sparse safe 2026-06-01 15:53:37 +02:00
Vincent Koc
408fa6e951 fix(test): stabilize watch-node shutdown tests 2026-06-01 15:53:37 +02:00
Vincent Koc
671909d6d3 refactor: share server aux reload test helpers 2026-06-01 15:51:05 +02:00
Vincent Koc
409f78a1ea fix(e2e): clean OTEL collector startup failures 2026-06-01 15:46:02 +02:00
Vincent Koc
3e592a8bd7 refactor: share mcp http loopback test helpers 2026-06-01 15:39:28 +02:00
Vincent Koc
e895479a21 fix(ci): fail gateway watch spawn errors promptly 2026-06-01 15:38:16 +02:00
Peter Steinberger
930bc9691b fix(ci): page CI timing job reads 2026-06-01 14:33:39 +01:00
Vincent Koc
b9f181635f fix(ci): fail gateway CPU spawn errors 2026-06-01 15:27:13 +02:00
Vincent Koc
c2aaf8afec refactor: share sessions patch test helpers 2026-06-01 15:17:55 +02:00
Vincent Koc
cbc5f277bb refactor: share session reset hook test helpers 2026-06-01 15:11:10 +02:00
Vincent Koc
44b388f863 fix(e2e): keep kitchen-sink process snapshots wide 2026-06-01 15:09:33 +02:00
Vincent Koc
c0e49a2c52 fix(e2e): catch runtime package-manager descendants 2026-06-01 14:58:39 +02:00
Peter Steinberger
c1e132195d test(release): activate manifest channels in bundle smoke 2026-06-01 13:51:38 +01:00
Vincent Koc
5bd8dbd0b8 refactor: share system run approval test helpers 2026-06-01 14:44:46 +02:00
Vincent Koc
421ea1f458 fix(e2e): bound Parallels host VM commands 2026-06-01 14:41:46 +02:00
Vincent Koc
1f91e97353 refactor: share startup secrets test helpers 2026-06-01 14:31:58 +02:00
Vincent Koc
d4f6e0a1f2 fix(docs): clean link audit temp docs 2026-06-01 14:26:21 +02:00
Peter Steinberger
ec2455a842 test(memory): drive timeout tests with explicit fake clocks
(cherry picked from commit d75eea53c9)
2026-06-01 13:12:07 +01:00
Vincent Koc
1742f3f77c refactor: share mcp http test helpers 2026-06-01 14:10:41 +02:00
Vincent Koc
5117f457bb fix(ci): clean gateway watch temp home 2026-06-01 14:09:58 +02:00
Vincent Koc
8fe5e83462 refactor: share sessions list changed test helpers 2026-06-01 14:00:20 +02:00
Vincent Koc
27097bed65 fix(ci): bound deadcode knip scan 2026-06-01 13:57:16 +02:00
Vincent Koc
1849a86dd2 refactor: share session history revocation helpers 2026-06-01 13:47:39 +02:00
Vincent Koc
5280d1d95d fix(e2e): stream Parallels phase logs 2026-06-01 13:46:21 +02:00
Vincent Koc
bcdc93d651 refactor: share auth compat backend scope assertion 2026-06-01 13:31:03 +02:00
Vincent Koc
0751b6f2c9 fix(e2e): bound upgrade survivor config commands 2026-06-01 13:30:23 +02:00
Peter Steinberger
7d9fae5b3a fix(memory): keep embedding timeout watchdog active
(cherry picked from commit 591f310869)
2026-06-01 12:29:27 +01:00
Vincent Koc
a595aba60e refactor: share sessions send result assertions 2026-06-01 13:21:09 +02:00
Vincent Koc
75645aec08 fix(e2e): clean Telegram proof child processes 2026-06-01 13:20:03 +02:00
Vincent Koc
d10d71cdb6 fix(codex): stabilize app-server cleanup tests 2026-06-01 13:15:05 +02:00
Vincent Koc
c69a8d633d perf(control-ui): hydrate chat startup state
Add a combined chat.startup gateway method for Control UI startup hydration so first chat load can receive history and agents in one RPC, while falling back to chat.history for older/unadvertised gateways. Verified with focused UI/gateway tests, tsgo/oxlint/diff checks, clean autoreview, and Testbox changed gate tbx_01kt1dt6fqdtdbprsk48z8fn71.
2026-06-01 12:14:19 +01:00
Vincent Koc
d8ebbedf45 refactor: share plugin http auth request assertions 2026-06-01 13:10:09 +02:00
Peter Steinberger
9ed1766696 test(whatsapp): align direct last-route envelope
(cherry picked from commit 5d902b0f20)
2026-06-01 12:04:51 +01:00
Vincent Koc
bed0fb7bad refactor: share session resolve assertions 2026-06-01 13:00:51 +02:00
Vincent Koc
db6fc20559 fix(e2e): clean Windows background smoke timeouts 2026-06-01 12:55:15 +02:00
Vincent Koc
1364acbe4c refactor: share gateway http stage error assertions 2026-06-01 12:45:20 +02:00
Vincent Koc
d2988e0248 refactor: share preview resolve alias fixtures 2026-06-01 12:42:30 +02:00
Vincent Koc
8c8c8c8e32 perf(control-ui): prioritize first connect startup (#89030)
* perf(control-ui): prioritize first connect startup

* fix(control-ui): close connect timing gaps

* fix(control-ui): default embeds strict before bootstrap

* fix(control-ui): keep bootstrap identity deferred

* fix(control-ui): gate startup chat on bootstrap

* fix(control-ui): restore composer after hello

* fix(control-ui): restore drafts before hello
2026-06-01 11:41:22 +01:00
Vincent Koc
8bee3be90a fix(e2e): bound Parallels fresh lanes 2026-06-01 12:34:29 +02:00
Vincent Koc
87d890003d refactor: share shutdown drain session setup 2026-06-01 12:31:32 +02:00
Peter Steinberger
aed7de306e fix(qa-matrix): detect sqlite dedupe commits by payload
(cherry picked from commit 2fc497e67b)
2026-06-01 11:27:10 +01:00
Vincent Koc
859cb52b44 refactor: share unauthorized response assertions 2026-06-01 12:22:58 +02:00
Vincent Koc
4685a84e9b fix(e2e): bound bundled runtime gateway cleanup 2026-06-01 12:19:37 +02:00
Vincent Koc
f30235bed2 test: fix gateway test type fixtures 2026-06-01 12:13:36 +02:00
Vincent Koc
4f8f6c7693 refactor: share thinking e2e session setup 2026-06-01 12:13:36 +02:00
Peter Steinberger
055063f06b fix(qa-matrix): read sqlite inbound dedupe state 2026-06-01 11:07:53 +01:00
Vincent Koc
dac33c8ecb fix(e2e): cap pty transcript output 2026-06-01 11:49:58 +02:00
Vincent Koc
75ebf1c870 refactor: share device token authz test helpers 2026-06-01 11:49:06 +02:00
Vincent Koc
e4a32b9e8e lint(e2e): remove redundant channel fallback 2026-06-01 11:38:28 +02:00
Vincent Koc
22e3b2e94e fix(dev): wait for watch-node shutdown 2026-06-01 11:38:28 +02:00
Peter Steinberger
729420c34a test: split slow vitest shards 2026-06-01 05:34:59 -04:00
749 changed files with 15932 additions and 6312 deletions

View File

@@ -798,7 +798,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
run: node scripts/build-all.mjs qaRuntime
- name: Run parity lane
env:
@@ -876,7 +876,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
run: node scripts/build-all.mjs qaRuntime
- name: Generate parity report
run: |
@@ -934,7 +934,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
run: node scripts/build-all.mjs qaRuntime
- name: Run runtime parity lane
id: runtime_parity_lane
@@ -1101,7 +1101,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
run: node scripts/build-all.mjs qaRuntime
- name: Run Matrix live lane
id: run_lane
@@ -1199,7 +1199,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
run: node scripts/build-all.mjs qaRuntime
- name: Run Telegram live lane
id: run_lane
@@ -1295,7 +1295,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
run: node scripts/build-all.mjs qaRuntime
- name: Run Discord live lane
id: run_lane
@@ -1393,7 +1393,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
run: node scripts/build-all.mjs qaRuntime
- name: Run WhatsApp live lane
id: run_lane
@@ -1488,7 +1488,7 @@ jobs:
- name: Build private QA runtime
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build
run: node scripts/build-all.mjs qaRuntime
- name: Run Slack live lane
id: run_lane

View File

@@ -1,2 +1,2 @@
63d49032a9b4dc4874a0ca17be73ecc97a2df5d1f47b4e72db34868423370558 plugin-sdk-api-baseline.json
af79f7d711afa0a8563782b8f5cdd7e46b9aea245f5e7ebc464327a8969ed65e plugin-sdk-api-baseline.jsonl
bdcf661ec680f79819096950295bdb04805aac9639477058d8855f294f6d8034 plugin-sdk-api-baseline.json
6b8c92cc5a9277f90973370102fa31efb23ffd93008c3ed961d38e4a8a3073b0 plugin-sdk-api-baseline.jsonl

View File

@@ -3218,7 +3218,7 @@ describe("active-memory plugin", () => {
testing.setSetupGraceTimeoutMsForTests(0);
api.pluginConfig = {
agents: ["main"],
timeoutMs: 100,
timeoutMs: 1_000,
};
plugin.register(api as unknown as OpenClawPluginApi);
hoisted.sessionStore["agent:main:memory-get-miss"] = {

View File

@@ -253,6 +253,8 @@ describe("codex media understanding provider", () => {
expect(result?.text).toBe("A red square.");
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
} finally {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
}
});

View File

@@ -82,6 +82,10 @@ export function createCodexSteeringQueue(params: {
batchedTexts.push({ text, resolve, reject });
clearBatchTimer();
const debounceMs = normalizeCodexSteerDebounceMs(options?.debounceMs);
if (debounceMs === 0) {
void flushBatch().catch(() => undefined);
return;
}
batchTimer = setTimeout(() => {
batchTimer = undefined;
void flushBatch().catch(() => undefined);

View File

@@ -9,6 +9,8 @@ describe("Codex app-server attempt turn watches", () => {
});
afterEach(() => {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
});
@@ -91,6 +93,28 @@ describe("Codex app-server attempt turn watches", () => {
expect(harness.abortController.signal.reason).toBe("turn_completion_idle_timeout");
});
it("prefers completion idle timeout when completion and progress watches are due together", () => {
const harness = createController();
harness.controller.armAttemptIdleWatch();
harness.controller.touchActivity("request:item/tool/call:response", {
arm: true,
attemptProgress: true,
attemptTimeoutMs: 10,
});
vi.advanceTimersByTime(10);
expect(harness.timeouts).toMatchObject([
{
kind: "completion",
idleMs: 10,
timeoutMs: 10,
lastActivityReason: "request:item/tool/call:response",
},
]);
expect(harness.abortController.signal.reason).toBe("turn_completion_idle_timeout");
});
it("clamps oversized completion idle timeouts before scheduling", () => {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
const harness = createController({

View File

@@ -166,6 +166,23 @@ export function createCodexAttemptTurnWatchController(params: {
scheduleTerminalIdleWatch();
}
function isCompletionIdleTimeoutDueBeforeAttempt(timeoutMs: number) {
if (
params.isCompleted() ||
params.isTerminalTurnNotificationQueued() ||
params.signal.aborted ||
!completionIdleWatchArmed ||
params.getActiveAppServerTurnRequests() > 0
) {
return false;
}
const completionTimeoutMs = completionIdleTimeoutOverrideMs ?? turnCompletionIdleTimeoutMs;
if (completionTimeoutMs > timeoutMs) {
return false;
}
return Math.max(0, Date.now() - completionLastActivityAt) >= completionTimeoutMs;
}
function recordAttemptProgress(
reason: string,
options?: { details?: Record<string, unknown>; attemptTimeoutMs?: number },
@@ -236,6 +253,10 @@ export function createCodexAttemptTurnWatchController(params: {
scheduleAttemptIdleWatch();
return;
}
if (isCompletionIdleTimeoutDueBeforeAttempt(timeoutMs)) {
fireCompletionIdleTimeout();
return;
}
const timeout = {
kind: "progress" as const,
idleMs,

View File

@@ -29,8 +29,8 @@ describe("CodexAppServerClient", () => {
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers();
for (const client of clients) {
client.close();
}

View File

@@ -17,8 +17,8 @@ import type { CodexDynamicToolCallResponse } from "./protocol.js";
describe("dynamic tool execution helpers", () => {
afterEach(() => {
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers();
});
it("keeps explicit dynamic tool timeouts above the default bridge deadline", () => {

View File

@@ -1,6 +1,7 @@
import fs from "node:fs/promises";
import path from "node:path";
import {
abortAndDrainAgentHarnessRun,
nativeHookRelayTesting,
queueAgentHarnessMessage,
resetAgentEventsForTest,
@@ -30,6 +31,8 @@ const appServerHarnessWait = { interval: 1, timeout: 120_000 } as const;
const activeAppServerAttemptsForTest = new Set<{
abortController?: AbortController;
promise: Promise<unknown>;
sessionId: string;
sessionKey?: string;
}>();
type RunCodexAppServerAttemptOptions = NonNullable<
@@ -62,6 +65,8 @@ export function runCodexAppServerAttempt(
const entry = {
abortController,
promise: undefined as unknown as Promise<unknown>,
sessionId: params.sessionId,
sessionKey: params.sessionKey,
};
const promise = runCodexAppServerAttemptImpl(
trackedParams,
@@ -76,6 +81,7 @@ export function runCodexAppServerAttempt(
}
async function drainActiveAppServerAttemptsForTest(): Promise<void> {
vi.useRealTimers();
const attempts = [...activeAppServerAttemptsForTest];
if (attempts.length === 0) {
return;
@@ -83,12 +89,33 @@ async function drainActiveAppServerAttemptsForTest(): Promise<void> {
for (const attempt of attempts) {
attempt.abortController?.abort("test_cleanup");
}
await Promise.race([
Promise.allSettled(attempts.map((attempt) => attempt.promise)),
new Promise<void>((resolve) => {
setTimeout(resolve, 5_000);
const drainedSessions = new Set<string>();
const sessionDrains = attempts.flatMap((attempt) => {
if (!attempt.sessionId || drainedSessions.has(attempt.sessionId)) {
return [];
}
drainedSessions.add(attempt.sessionId);
return [
abortAndDrainAgentHarnessRun({
sessionId: attempt.sessionId,
sessionKey: attempt.sessionKey,
settleMs: 1_000,
forceClear: true,
reason: "test_cleanup",
}).catch(() => undefined),
];
});
const drainResult = await Promise.race([
Promise.allSettled([...attempts.map((attempt) => attempt.promise), ...sessionDrains]).then(
() => "settled" as const,
),
new Promise<"timeout">((resolve) => {
setTimeout(() => resolve("timeout"), 5_000);
}),
]);
if (drainResult === "settled") {
activeAppServerAttemptsForTest.clear();
}
}
export function createParams(sessionFile: string, workspaceDir: string): EmbeddedRunAttemptParams {
@@ -490,8 +517,8 @@ export function setupRunAttemptTestHooks(): void {
resetGlobalHookRunner();
clearInternalHooks();
defaultCodexAppInventoryCache.clear();
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers();
vi.unstubAllEnvs();
await closeCodexSandboxExecServersForTests();
await fs.rm(tempDir, { recursive: true, force: true });

View File

@@ -133,8 +133,8 @@ describe("shared Codex app-server client", () => {
afterEach(() => {
resetSharedCodexAppServerClientForTests();
vi.useRealTimers();
vi.restoreAllMocks();
vi.useRealTimers();
mocks.bridgeCodexAppServerStartOptions.mockClear();
mocks.applyCodexAppServerAuthProfile.mockClear();
mocks.resolveCodexAppServerAuthProfileIdForAgent.mockClear();

View File

@@ -262,7 +262,12 @@ export async function createIsolatedCodexAppServerClient(
export function resetSharedCodexAppServerClientForTests(): void {
const state = getSharedCodexAppServerClientState();
const clients = collectSharedClients(state);
state.clients.clear();
state.leasedReleases = new WeakMap();
for (const client of clients) {
client.close();
}
}
export function clearSharedCodexAppServerClient(): void {

View File

@@ -186,6 +186,7 @@ describe("codex conversation turn collector", () => {
await vi.advanceTimersByTimeAsync(100);
await assertion;
} finally {
vi.restoreAllMocks();
vi.useRealTimers();
}
});
@@ -206,6 +207,8 @@ describe("codex conversation turn collector", () => {
await expect(completion).resolves.toEqual({ replyText: "" });
} finally {
vi.restoreAllMocks();
vi.clearAllTimers();
vi.useRealTimers();
}
});

View File

@@ -2154,8 +2154,8 @@ describe("DiscordVoiceManager", () => {
expect(entersStateMock).toHaveBeenCalledWith(connection, "signalling", 20_000);
expect(entersStateMock).toHaveBeenCalledWith(connection, "connecting", 20_000);
expect(connection.destroy).toHaveBeenCalledTimes(1);
expect(manager.status()).toStrictEqual([]);
await vi.waitFor(() => expect(connection.destroy).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(manager.status()).toStrictEqual([]));
});
it("uses the default reconnect grace before destroying disconnected sessions", async () => {
@@ -2175,8 +2175,8 @@ describe("DiscordVoiceManager", () => {
expect(entersStateMock).toHaveBeenCalledWith(connection, "signalling", 15_000);
expect(entersStateMock).toHaveBeenCalledWith(connection, "connecting", 15_000);
expect(connection.destroy).toHaveBeenCalledTimes(1);
expect(manager.status()).toStrictEqual([]);
await vi.waitFor(() => expect(connection.destroy).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(manager.status()).toStrictEqual([]));
});
it("closes realtime sessions when disconnected recovery destroys the connection", async () => {
@@ -2201,9 +2201,9 @@ describe("DiscordVoiceManager", () => {
expect(disconnected).toBeTypeOf("function");
await disconnected?.();
expect(realtimeSessionMock.close).toHaveBeenCalledTimes(1);
expect(connection.destroy).toHaveBeenCalledTimes(1);
expect(manager.status()).toStrictEqual([]);
await vi.waitFor(() => expect(realtimeSessionMock.close).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(connection.destroy).toHaveBeenCalledTimes(1));
await vi.waitFor(() => expect(manager.status()).toStrictEqual([]));
});
it("closes realtime sessions when Discord destroys the connection", async () => {

View File

@@ -143,7 +143,6 @@ export async function runEmbeddingOperationWithTimeout<T>(params: {
reject(error);
controller.abort(error);
}, timeoutMs);
timer.unref?.();
});
try {
const operation = params.run(controller.signal);

View File

@@ -102,9 +102,10 @@ describe("memory embedding timeout abort", () => {
});
it("aborts the provider operation when the timeout fires", async () => {
vi.useFakeTimers();
let signalSeen: AbortSignal | undefined;
await expect(
const result = expect(
runEmbeddingOperationWithTimeout({
timeoutMs: 1,
message: "memory embeddings query timed out after 0s",
@@ -120,12 +121,15 @@ describe("memory embedding timeout abort", () => {
},
}),
).rejects.toThrow("memory embeddings query timed out after 0s");
await vi.advanceTimersByTimeAsync(1);
await result;
expect(signalSeen?.aborted).toBe(true);
});
it("keeps the timeout error when a provider abort listener rejects generically", async () => {
await expect(
vi.useFakeTimers();
const result = expect(
runEmbeddingOperationWithTimeout({
timeoutMs: 1,
message: "memory embeddings batch timed out after 0s",
@@ -137,6 +141,8 @@ describe("memory embedding timeout abort", () => {
}),
}),
).rejects.toThrow("memory embeddings batch timed out after 0s");
await vi.advanceTimersByTimeAsync(1);
await result;
});
it("caps operation watchdog timers before scheduling", async () => {

View File

@@ -740,6 +740,7 @@ describe("QmdMemoryManager", () => {
});
it("times out collection bootstrap commands", async () => {
vi.useFakeTimers();
cfg = {
...cfg,
memory: {
@@ -764,7 +765,15 @@ describe("QmdMemoryManager", () => {
return createMockChild();
});
const { manager } = await createManager({ mode: "full" });
const managerPromise = createManager({ mode: "full" });
await waitUntil(() =>
spawnMock.mock.calls.some((call: unknown[]) => {
const args = call[1] as string[];
return args[0] === "collection" && args[1] === "list";
}),
);
await vi.advanceTimersByTimeAsync(15);
const { manager } = await managerPromise;
const status = manager.status();
expect(status.backend).toBe("qmd");
expect(status.requestedProvider).toBe("qmd");
@@ -4396,6 +4405,7 @@ describe("QmdMemoryManager", () => {
});
it("retries boot update when qmd reports a retryable lock error", async () => {
vi.useFakeTimers();
cfg = {
...cfg,
memory: {
@@ -4429,26 +4439,14 @@ describe("QmdMemoryManager", () => {
return createMockChild();
});
const nativeSetTimeout = globalThis.setTimeout;
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout").mockImplementation(((
handler: TimerHandler,
timeout?: number,
...args: unknown[]
) => {
if (typeof timeout === "number" && timeout >= 500) {
return nativeSetTimeout(handler, 1, ...args);
}
return nativeSetTimeout(handler, timeout, ...args);
}) as typeof globalThis.setTimeout);
const managerPromise = createManager({ mode: "full" });
await waitUntil(() => updateCalls === 1);
await vi.advanceTimersByTimeAsync(500);
await waitUntil(() => updateCalls === 2);
const { manager } = await managerPromise;
const { manager } = await createManager({ mode: "full" });
try {
expect(updateCalls).toBe(2);
await manager.close();
} finally {
setTimeoutSpy.mockRestore();
}
expect(updateCalls).toBe(2);
await manager.close();
});
it("succeeds on qmd update even when stdout exceeds the output cap", async () => {

View File

@@ -1169,8 +1169,8 @@ describe("memory plugin e2e", () => {
test("clamps oversized auto-recall timeout timers", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
await expect(
testing.runWithTimeout({
timeoutMs: Number.MAX_SAFE_INTEGER,
@@ -1180,14 +1180,15 @@ describe("memory plugin e2e", () => {
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
} finally {
setTimeoutSpy.mockRestore();
vi.useRealTimers();
}
});
test("falls back for invalid auto-recall timeout timers", async () => {
vi.useFakeTimers();
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
await expect(
testing.runWithTimeout({
timeoutMs: Number.NaN,
@@ -1197,6 +1198,7 @@ describe("memory plugin e2e", () => {
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), 1);
} finally {
setTimeoutSpy.mockRestore();
vi.useRealTimers();
}
});

View File

@@ -208,6 +208,8 @@ describe("openrouter music generation provider", () => {
});
it("caps oversized OpenRouter music stream timeouts", async () => {
vi.useFakeTimers();
vi.setSystemTime(1_000);
const timeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
postJsonRequestMock.mockResolvedValue({
@@ -231,6 +233,7 @@ describe("openrouter music generation provider", () => {
expect(streamTimeoutMs).toBeLessThanOrEqual(MAX_TIMER_TIMEOUT_MS);
} finally {
timeoutSpy.mockRestore();
vi.useRealTimers();
}
});

View File

@@ -25,11 +25,15 @@ describe("mantis visual task runtime", () => {
let repoRoot: string;
beforeEach(async () => {
vi.restoreAllMocks();
vi.useRealTimers();
repoRoot = await fs.mkdtemp(path.join(os.tmpdir(), "mantis-visual-task-"));
});
afterEach(async () => {
await fs.rm(repoRoot, { force: true, recursive: true });
vi.restoreAllMocks();
vi.useRealTimers();
});
it("records a visible browser task and keeps screenshot/video artifacts", async () => {

View File

@@ -520,9 +520,12 @@ export async function runMantisVisualDriver(
runner,
stdio: "inherit",
});
await new Promise((resolve) => {
setTimeout(resolve, opts.settleMs ?? DEFAULT_SETTLE_MS);
});
const settleMs = opts.settleMs ?? DEFAULT_SETTLE_MS;
if (settleMs > 0) {
await new Promise((resolve) => {
setTimeout(resolve, settleMs);
});
}
await runCommandWithExternalOutput({
command: crabboxBin,
outputPath: screenshotPath,

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import fs from "node:fs/promises";
import path from "node:path";
import { setTimeout as sleep } from "node:timers/promises";
@@ -6,6 +7,8 @@ import type { MatrixQaScenarioContext } from "./scenario-runtime-shared.js";
const MATRIX_SYNC_STORE_FILENAME = "bot-storage.json";
const MATRIX_INBOUND_DEDUPE_FILENAME = "inbound-dedupe.json";
const MATRIX_PLUGIN_ID = "matrix";
const MATRIX_INBOUND_DEDUPE_NAMESPACE = "inbound-dedupe";
const MATRIX_STATE_POLL_INTERVAL_MS = 100;
async function readJsonFile(pathname: string): Promise<unknown> {
@@ -191,6 +194,92 @@ function hasPersistedMatrixDedupeEntry(params: {
return params.parsed.entries.some((entry) => isRecord(entry) && entry.key === expectedKey);
}
function buildMatrixInboundDedupePluginStateKey(params: {
accountId: string;
eventId: string;
roomId: string;
}): string {
const accountId = params.accountId.trim() || "sut";
const roomId = params.roomId.trim();
const eventId = params.eventId.trim();
const digest = createHash("sha256")
.update(accountId)
.update("\0")
.update(roomId)
.update("\0")
.update(eventId)
.digest("hex");
return `${accountId}:${digest}`;
}
async function hasPersistedMatrixPluginStateDedupeEntry(params: {
accountId: string;
eventId: string;
roomId: string;
stateDir: string;
}): Promise<string | null> {
const entryKey = buildMatrixInboundDedupePluginStateKey({
accountId: params.accountId,
eventId: params.eventId,
roomId: params.roomId,
});
const databasePaths = await findFilesByName({
filename: "openclaw.sqlite",
rootDir: params.stateDir,
maxDepth: 4,
});
if (databasePaths.length === 0) {
databasePaths.push(path.join(params.stateDir, "state", "openclaw.sqlite"));
}
const now = Date.now();
const isExpectedValue = (raw: unknown) => {
if (typeof raw !== "string") {
return false;
}
try {
const parsed = JSON.parse(raw) as unknown;
return (
isRecord(parsed) && parsed.roomId === params.roomId && parsed.eventId === params.eventId
);
} catch {
return false;
}
};
try {
const sqlite = await import("node:sqlite");
for (const databasePath of databasePaths) {
try {
await fs.access(databasePath);
const db = new sqlite.DatabaseSync(databasePath, { readOnly: true });
try {
const rows = db
.prepare(
`SELECT entry_key AS entryKey, value_json AS valueJson
FROM plugin_state_entries
WHERE plugin_id = ?
AND namespace = ?
AND (expires_at IS NULL OR expires_at > ?)`,
)
.all(MATRIX_PLUGIN_ID, MATRIX_INBOUND_DEDUPE_NAMESPACE, now) as Array<{
entryKey?: unknown;
valueJson?: unknown;
}>;
if (rows.some((row) => row.entryKey === entryKey || isExpectedValue(row.valueJson))) {
return databasePath;
}
} finally {
db.close();
}
} catch {
continue;
}
}
} catch {
return null;
}
return null;
}
export async function waitForMatrixInboundDedupeEntry(params: {
context: MatrixQaScenarioContext;
eventId: string;
@@ -200,6 +289,15 @@ export async function waitForMatrixInboundDedupeEntry(params: {
}) {
const startedAt = Date.now();
while (Date.now() - startedAt < params.timeoutMs) {
const sqlitePath = await hasPersistedMatrixPluginStateDedupeEntry({
accountId: params.context.sutAccountId ?? "sut",
eventId: params.eventId,
roomId: params.roomId,
stateDir: params.stateDir,
});
if (sqlitePath) {
return sqlitePath;
}
const pathname = await resolveBestMatrixStateFile({
context: params.context,
filename: MATRIX_INBOUND_DEDUPE_FILENAME,

View File

@@ -1,3 +1,4 @@
import { createHash } from "node:crypto";
import { mkdir, mkdtemp, readFile, readdir, rm, stat, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
@@ -60,6 +61,69 @@ const MATRIX_SUBAGENT_MISSING_HOOK_ERROR =
"thread=true is unavailable because no channel plugin registered subagent_spawning hooks.";
const MATRIX_QA_HOT_RELOAD_RESTART_DELAY_MS = 300_000;
function matrixInboundDedupePluginStateKey(params: {
accountId: string;
eventId: string;
roomId: string;
}): string {
const accountId = params.accountId.trim() || "sut";
const digest = createHash("sha256")
.update(accountId)
.update("\0")
.update(params.roomId.trim())
.update("\0")
.update(params.eventId.trim())
.digest("hex");
return `${accountId}:${digest}`;
}
async function writeMatrixInboundDedupePluginStateEntry(params: {
accountId: string;
eventId: string;
roomId: string;
stateRoot: string;
}) {
const sqlite = await import("node:sqlite");
const databasePath = path.join(params.stateRoot, "state", "openclaw.sqlite");
await mkdir(path.dirname(databasePath), { recursive: true });
const db = new sqlite.DatabaseSync(databasePath);
try {
db.exec(`
CREATE TABLE IF NOT EXISTS plugin_state_entries (
plugin_id TEXT NOT NULL,
namespace TEXT NOT NULL,
entry_key TEXT NOT NULL,
value_json TEXT NOT NULL,
created_at INTEGER NOT NULL,
expires_at INTEGER,
PRIMARY KEY (plugin_id, namespace, entry_key)
);
`);
db.prepare(`
INSERT INTO plugin_state_entries (
plugin_id, namespace, entry_key, value_json, created_at, expires_at
) VALUES (?, ?, ?, ?, ?, ?)
ON CONFLICT(plugin_id, namespace, entry_key) DO UPDATE SET
value_json = excluded.value_json,
created_at = excluded.created_at,
expires_at = excluded.expires_at
`).run(
"matrix",
"inbound-dedupe",
matrixInboundDedupePluginStateKey(params),
JSON.stringify({
roomId: params.roomId,
eventId: params.eventId,
ts: Date.now(),
}),
Date.now(),
null,
);
} finally {
db.close();
}
}
function requireMatrixQaScenario(id: string): (typeof MATRIX_QA_SCENARIOS)[number] {
const scenario = MATRIX_QA_SCENARIOS.find((entry) => entry.id === id);
if (!scenario) {
@@ -1958,7 +2022,6 @@ describe("matrix live qa scenarios", () => {
const accountDir = path.join(stateRoot, "matrix", "accounts", "sut", "server", "token");
const staleSyncRoomId = "!stale-sync:matrix-qa.test";
const syncStorePath = path.join(accountDir, "bot-storage.json");
const dedupeStorePath = path.join(accountDir, "inbound-dedupe.json");
await mkdir(accountDir, { recursive: true });
await writeTestJsonFile(path.join(accountDir, "storage-meta.json"), {
accountId: "sut",
@@ -1983,14 +2046,11 @@ describe("matrix live qa scenarios", () => {
const kind = token.includes("STALE_SYNC_DEDUPE_FRESH") ? "fresh" : "first";
callOrder.push(`wait:${kind}`);
if (kind === "first") {
await writeTestJsonFile(dedupeStorePath, {
version: 1,
entries: [
{
key: `${staleSyncRoomId}|$first-trigger`,
ts: Date.now(),
},
],
await writeMatrixInboundDedupePluginStateEntry({
accountId: "runtime-default",
eventId: "$first-trigger",
roomId: staleSyncRoomId,
stateRoot,
});
}
return {

View File

@@ -6,6 +6,16 @@
"onStartup": false
},
"channels": ["slack"],
"channelConfigs": {
"slack": {
"label": "Slack",
"description": "Slack channel, DM, command, and app event integration.",
"schema": {
"type": "object",
"additionalProperties": true
}
}
},
"channelEnvVars": {
"slack": ["SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", "SLACK_USER_TOKEN"]
},

View File

@@ -998,11 +998,15 @@ describe("web auto-reply connection", () => {
const firstPattern = escapeRegExp(firstTimestamp);
const secondPattern = escapeRegExp(secondTimestamp);
expect(firstArgs.Body).toMatch(
new RegExp(`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${firstPattern}\\] \\[openclaw\\] first`),
new RegExp(
`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${firstPattern}\\] \\+1: \\[openclaw\\] first`,
),
);
expect(firstArgs.Body).not.toContain("second");
expect(secondArgs.Body).toMatch(
new RegExp(`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${secondPattern}\\] \\[openclaw\\] second`),
new RegExp(
`\\[WhatsApp \\+1 (\\+\\d+[smhd] )?${secondPattern}\\] \\+1: \\[openclaw\\] second`,
),
);
expect(secondArgs.Body).not.toContain("first");
expect(process.getMaxListeners?.()).toBeGreaterThanOrEqual(50);

View File

@@ -1,5 +1,4 @@
import "./test-helpers.js";
import { formatInboundEnvelope } from "openclaw/plugin-sdk/channel-inbound";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
@@ -151,17 +150,6 @@ describe("web auto-reply last-route", () => {
to: "+1000",
accountId: "default",
});
const body = formatInboundEnvelope({
channel: "WhatsApp",
from: "+1000",
timestamp: now,
body: "hello",
chatType: "direct",
sender: {
e164: "+1000",
id: "+1000",
},
});
expect(ctx).toMatchObject({
From: "+1000",
To: "+2000",
@@ -178,7 +166,7 @@ describe("web auto-reply last-route", () => {
SenderE164: "+1000",
SenderId: "+1000",
RawBody: "hello",
Body: body,
Body: expect.stringMatching(/^\[WhatsApp \+1000 .+\] \+1000: hello$/),
BodyForAgent: "hello",
CommandBody: "hello",
Timestamp: now,

View File

@@ -201,16 +201,29 @@ function resolveSenderLabelMock(sender?: TestInboundEnvelopeParams["sender"]) {
return display || idPart || undefined;
}
function resolveDirectEnvelopeBodyLabelMock(from?: string) {
const label = sanitizeEnvelopeHeaderPart(from?.trim() || "");
const idMarkerIndex = label.search(/\s+id:/i);
if (idMarkerIndex > 0) {
const displayLabel = label.slice(0, idMarkerIndex).trim();
return displayLabel.includes(":") ? "(sender)" : displayLabel;
}
return label.includes(":") ? "(sender)" : label;
}
function formatInboundEnvelopeMock(params: TestInboundEnvelopeParams) {
const chatType = normalizeLowercaseStringOrEmpty(params.chatType);
const isDirect = !chatType || chatType === "direct";
const sender = params.senderLabel?.trim() || resolveSenderLabelMock(params.sender);
const directSender = resolveDirectEnvelopeBodyLabelMock(params.from);
const body =
isDirect && params.fromMe
? `(self): ${params.body}`
: !isDirect && sender
? `${sanitizeEnvelopeHeaderPart(sender)}: ${params.body}`
: params.body;
: isDirect && directSender
? `${directSender}: ${params.body}`
: !isDirect && sender
? `${sanitizeEnvelopeHeaderPart(sender)}: ${params.body}`
: params.body;
const parts = [sanitizeEnvelopeHeaderPart(params.channel?.trim() || "Channel")];
const from = params.from?.trim();
if (from) {

View File

@@ -28,13 +28,18 @@ import { buildDeviceAuthPayloadV3 } from "./device-auth.js";
import { resolveConnectChallengeTimeoutMs, resolveSafeTimeoutDelayMs } from "./timeouts.js";
export type DeviceIdentity = {
/** Stable gateway device id associated with this keypair. */
deviceId: string;
/** PEM private key used by host deps to sign device-auth payloads. */
privateKeyPem: string;
/** PEM public key sent to the gateway during device pairing/auth. */
publicKeyPem: string;
};
export type DeviceAuthTokenRecord = {
/** Stored device bearer token returned by the gateway. */
token?: string;
/** Scopes granted to the stored token; reused only when still sufficient. */
scopes?: string[];
};
@@ -306,8 +311,11 @@ type Pending = {
};
export type GatewayClientRequestOptions = {
/** Wait for an accepted response followed by a final response. */
expectFinal?: boolean;
/** Per-request timeout; null disables request timeout scheduling. */
timeoutMs?: number | null;
/** Cancels the request and removes its pending response handler. */
signal?: AbortSignal;
/** Called once for expectFinal requests after an accepted response, before the final result. */
onAccepted?: (payload: unknown) => void;
@@ -355,11 +363,15 @@ const DEFAULT_GATEWAY_CLIENT_URL = "ws://127.0.0.1:18789";
const DEFAULT_CLIENT_VERSION = "0.0.0";
export type GatewayReconnectPausedInfo = {
/** WebSocket close code that paused reconnect attempts. */
code: number;
/** Raw close reason supplied by the gateway/socket. */
reason: string;
/** Structured connect-error detail code when the close came from gateway auth/startup. */
detailCode: string | null;
};
/** Error wrapper for gateway response frames that preserves retry metadata for callers. */
export class GatewayClientRequestError extends Error {
readonly gatewayCode: string;
readonly details?: unknown;
@@ -397,8 +409,10 @@ export function isGatewayConnectAssemblyError(value: unknown): value is Error {
);
}
/** Construction options for GatewayClient connections, auth, protocol bounds, and callbacks. */
export type GatewayClientOptions = {
url?: string; // ws://127.0.0.1:18789
/** Client-side watchdog for receiving the connect challenge. */
connectChallengeTimeoutMs?: number;
/** @deprecated Use connectChallengeTimeoutMs. */
connectDelayMs?: number;
@@ -450,6 +464,7 @@ export const GATEWAY_CLOSE_CODE_HINTS: Readonly<Record<number, string>> = {
1013: "try again later",
};
/** Returns the short operator-facing description for common gateway close codes. */
export function describeGatewayCloseCode(code: number): string | undefined {
return GATEWAY_CLOSE_CODE_HINTS[code];
}
@@ -490,6 +505,8 @@ export function resolveGatewayClientConnectChallengeTimeoutMs(
"connectChallengeTimeoutMs" | "connectDelayMs" | "preauthHandshakeTimeoutMs"
>,
): number {
// Keep the legacy connectDelayMs alias feeding the same clamp path until the
// public option is removed; explicit challenge timeout still wins.
return resolveConnectChallengeTimeoutMs(readConnectChallengeTimeoutOverride(opts), {
configuredTimeoutMs: opts.preauthHandshakeTimeoutMs,
});

View File

@@ -1,3 +1,7 @@
/**
* Normalizes optional device metadata before it becomes part of a signed auth
* payload.
*/
export function normalizeDeviceMetadataForAuth(value?: string | null): string {
if (typeof value !== "string") {
return "";
@@ -6,25 +10,38 @@ export function normalizeDeviceMetadataForAuth(value?: string | null): string {
if (!trimmed) {
return "";
}
// Preserve the gateway's historical ASCII-only case fold; locale-sensitive
// lowercasing would change existing signatures for non-ASCII device names.
return trimmed.replace(/[A-Z]/g, (char) => String.fromCharCode(char.charCodeAt(0) + 32));
}
type DeviceAuthPayloadParams = {
/** Stable device id paired with the gateway. */
deviceId: string;
/** Client application id, such as the desktop or mobile client. */
clientId: string;
/** Gateway client mode included in the signed payload. */
clientMode: string;
/** Requested gateway role for the authenticated device. */
role: string;
/** Ordered scope list; order is signature-significant. */
scopes: string[];
/** Signing timestamp in epoch milliseconds. */
signedAtMs: number;
/** Optional bootstrap token; null/undefined still reserves the v2/v3 field. */
token?: string | null;
/** Per-request nonce included to prevent replay. */
nonce: string;
};
type DeviceAuthPayloadV3Params = DeviceAuthPayloadParams & {
/** Optional normalized platform metadata appended after the v2 fields. */
platform?: string | null;
/** Optional normalized device-family metadata appended after platform. */
deviceFamily?: string | null;
};
/** Builds the canonical v2 device-auth string that the gateway verifies byte-for-byte. */
export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string {
const scopes = params.scopes.join(",");
const token = params.token ?? "";
@@ -41,6 +58,7 @@ export function buildDeviceAuthPayload(params: DeviceAuthPayloadParams): string
].join("|");
}
/** Builds the canonical v3 device-auth string with normalized platform/family metadata. */
export function buildDeviceAuthPayloadV3(params: DeviceAuthPayloadV3Params): string {
const scopes = params.scopes.join(",");
const token = params.token ?? "";

View File

@@ -2,19 +2,29 @@ import { resolveFiniteTimeoutDelayMs } from "./timeouts.js";
/** Readiness probe outcome with timing data for diagnosing event-loop stalls. */
export type EventLoopReadyResult = {
/** True when enough consecutive timer checks stayed below the drift threshold. */
ready: boolean;
/** Wall-clock time spent in the readiness probe. */
elapsedMs: number;
/** Largest observed timer drift across all checks. */
maxDriftMs: number;
/** Number of scheduled timer checks that fired before completion. */
checks: number;
/** True when the supplied AbortSignal stopped the probe before readiness or timeout. */
aborted: boolean;
};
/** Controls how aggressively the client waits for low-drift timer checks before starting IO. */
export type EventLoopReadyOptions = {
/** Maximum wall-clock time to wait before reporting not ready. */
maxWaitMs?: number;
/** Delay between drift samples; clamped to safe Node timer bounds. */
intervalMs?: number;
/** Maximum acceptable timer drift for a sample to count as ready. */
driftThresholdMs?: number;
/** Number of low-drift samples required before the event loop is considered ready. */
consecutiveReadyChecks?: number;
/** Cancels the probe without starting client IO. */
signal?: AbortSignal;
};
@@ -104,6 +114,8 @@ export async function waitForEventLoopReady(
if (driftMs > driftThresholdMs) {
readyChecks = 0;
} else {
// Require consecutive low-drift samples so one lucky timer after a
// blocked loop does not start IO while the process is still saturated.
readyChecks += 1;
}
if (readyChecks >= consecutiveReadyChecks) {

View File

@@ -7,6 +7,7 @@ import {
import { resolveConnectChallengeTimeoutMs } from "./timeouts.js";
export type GatewayClientStartable = {
/** Starts the underlying gateway connection after readiness succeeds. */
start(): void;
};
@@ -17,11 +18,14 @@ export type EventLoopReadyWaiter = (
/** Timeout and abort controls for delaying client start until the loop can process IO. */
export type GatewayClientStartReadinessOptions = {
/** Explicit readiness wait cap; wins over client connection timeout settings. */
timeoutMs?: number;
/** Client connection settings used to derive a readiness cap when timeoutMs is absent. */
clientOptions?: Pick<
GatewayClientOptions,
"connectChallengeTimeoutMs" | "connectDelayMs" | "preauthHandshakeTimeoutMs"
>;
/** Cancels readiness without starting the client. */
signal?: AbortSignal;
};
@@ -33,6 +37,8 @@ function resolveGatewayClientStartReadinessTimeoutMs(
}
const clientOptions = options.clientOptions ?? {};
const timeoutOverride =
// Prefer the challenge watchdog over the older connectDelayMs alias so
// readiness stays aligned with the server-side preauth handshake window.
typeof clientOptions.connectChallengeTimeoutMs === "number" &&
Number.isFinite(clientOptions.connectChallengeTimeoutMs)
? clientOptions.connectChallengeTimeoutMs
@@ -55,6 +61,8 @@ export async function startGatewayClientWithReadinessWait(
maxWaitMs: resolveGatewayClientStartReadinessTimeoutMs(options),
signal: options.signal,
});
// The readiness waiter can race with abort delivery; gate start on both the
// returned state and the current signal so aborted startup remains side-effect-free.
if (readiness.ready && !readiness.aborted && options.signal?.aborted !== true) {
client.start();
}

View File

@@ -1,5 +1,7 @@
function parseStrictPositiveInteger(value: string): number | undefined {
const trimmed = value.trim();
// Env overrides accept only decimal integers so units/decimals do not
// silently truncate into a shorter timeout.
if (!/^\+?\d+$/u.test(trimmed)) {
return undefined;
}
@@ -19,6 +21,8 @@ export const MAX_CONNECT_CHALLENGE_TIMEOUT_MS = DEFAULT_PREAUTH_HANDSHAKE_TIMEOU
/** Clamps arbitrary timer delays to Node's safe range and an optional floor. */
export function resolveSafeTimeoutDelayMs(delayMs: number, opts?: { minMs?: number }): number {
const rawMinMs = opts?.minMs ?? 1;
// Clamp the floor first; callers can opt into immediate timers with minMs=0,
// but invalid floors still fall back to the nonzero default timeout guard.
const minMs = Math.min(
MAX_SAFE_TIMEOUT_DELAY_MS,
Math.max(0, Number.isFinite(rawMinMs) ? Math.floor(rawMinMs) : 1),
@@ -59,6 +63,8 @@ export function clampConnectChallengeTimeoutMs(
timeoutMs: number,
maxTimeoutMs = MAX_CONNECT_CHALLENGE_TIMEOUT_MS,
): number {
// Keep the upper bound at least as large as the watchdog floor so callers
// cannot invert the clamp range with an undersized configured server timeout.
return Math.max(
MIN_CONNECT_CHALLENGE_TIMEOUT_MS,
Math.min(Math.max(MIN_CONNECT_CHALLENGE_TIMEOUT_MS, maxTimeoutMs), timeoutMs),
@@ -105,6 +111,8 @@ export function resolveConnectChallengeTimeoutMs(
}
const envOverride = getConnectChallengeTimeoutMsFromEnv(params?.env);
if (envOverride !== undefined) {
// Explicit client overrides are allowed to exceed the server-derived cap
// for tests and slow environments; still apply the lower watchdog floor.
return clampConnectChallengeTimeoutMs(envOverride, Math.max(maxTimeoutMs, envOverride));
}
return clampConnectChallengeTimeoutMs(configuredPreauthTimeoutMs, maxTimeoutMs);

View File

@@ -1,7 +1,10 @@
import { scanFenceSpans, type FenceScanState, type FenceSpan } from "./fences.js";
/** Incremental inline-code scanner state carried between streamed chunks. */
export type InlineCodeState = {
/** True when a previous chunk opened a backtick run that has not closed yet. */
open: boolean;
/** Backtick run length required to close the current inline-code span. */
ticks: number;
};
@@ -21,7 +24,7 @@ type CodeSpanIndex = {
isInside: (index: number) => boolean;
};
/** Builds a lookup for fenced and inline code spans while preserving scanner state. */
/** Builds a zero-based code-region lookup for fenced and inline spans, plus next scanner state. */
export function buildCodeSpanIndex(
text: string,
inlineState?: InlineCodeState,
@@ -59,6 +62,7 @@ function parseInlineCodeSpans(
while (i < text.length) {
const fence = findFenceSpanAtInclusive(fenceSpans, i);
if (fence) {
// Fenced code owns its full range; inline backticks inside it must not change state.
i = fence.end;
continue;
}
@@ -91,6 +95,7 @@ function parseInlineCodeSpans(
}
if (open) {
// Treat an unfinished span as code through chunk end so partial tags stay protected.
spans.push([openStart, text.length]);
}

View File

@@ -28,6 +28,7 @@ type MarkdownToken = {
level?: number;
};
/** Style categories tracked as ranges over rendered plaintext. */
export type MarkdownStyle =
| "bold"
| "italic"
@@ -37,19 +38,23 @@ export type MarkdownStyle =
| "spoiler"
| "blockquote";
/** Half-open style range in `MarkdownIR.text`; `end` is exclusive. */
export type MarkdownStyleSpan = {
start: number;
end: number;
style: MarkdownStyle;
/** Fence language info for code blocks when markdown-it provided one. */
language?: string;
};
/** Half-open link-label range in `MarkdownIR.text` with the original href. */
export type MarkdownLinkSpan = {
start: number;
end: number;
href: string;
};
/** Plaintext markdown projection plus style/link ranges into that text. */
export type MarkdownIR = {
text: string;
styles: MarkdownStyleSpan[];
@@ -68,11 +73,13 @@ function createStyleSpan(params: MarkdownStyleSpan): MarkdownStyleSpan {
return span;
}
/** Parsed table text after markdown inline rendering has been applied per cell. */
export type MarkdownTableData = {
headers: string[];
rows: string[][];
};
/** Table metadata collected for block-mode rendering with the placeholder location. */
export type MarkdownTableMeta = MarkdownTableData & {
placeholderOffset: number;
};
@@ -116,10 +123,15 @@ type RenderState = RenderTarget & {
};
export type MarkdownParseOptions = {
/** Enable markdown-it linkify conversion. Default: true. */
linkify?: boolean;
/** Interpret paired `||` text delimiters as spoiler style spans. Default: false. */
enableSpoilers?: boolean;
/** Whether headings should become bold spans or plain text. Default: none. */
headingStyle?: "none" | "bold";
/** Text prefix inserted at each blockquote open before applying blockquote style. */
blockquotePrefix?: string;
/** Enable markdown-it autolinks. Default: true unless explicitly false. */
autolink?: boolean;
/** How to render tables (off|bullets|code|block). Default: off. */
tableMode?: MarkdownTableMode;
@@ -966,6 +978,7 @@ function sliceLinkSpans(spans: MarkdownLinkSpan[], start: number, end: number):
return sliced;
}
/** Slices IR text and rebases overlapping style/link spans into the returned range. */
export function sliceMarkdownIR(ir: MarkdownIR, start: number, end: number): MarkdownIR {
return {
text: ir.text.slice(start, end),
@@ -974,10 +987,12 @@ export function sliceMarkdownIR(ir: MarkdownIR, start: number, end: number): Mar
};
}
/** Parses markdown into plaintext plus style/link ranges. */
export function markdownToIR(markdown: string, options: MarkdownParseOptions = {}): MarkdownIR {
return markdownToIRWithMeta(markdown, options).ir;
}
/** Parses markdown into IR and returns table-detection metadata for table-aware callers. */
export function markdownToIRWithMeta(
markdown: string,
options: MarkdownParseOptions = {},
@@ -1040,6 +1055,7 @@ export function markdownToIRWithMeta(
};
}
/** Chunks IR text at readable boundaries and rebases style/link spans per chunk. */
export function chunkMarkdownIR(ir: MarkdownIR, limit: number): MarkdownIR[] {
if (!ir.text) {
return [];

View File

@@ -1,12 +1,15 @@
import type { MarkdownIR, MarkdownLinkSpan, MarkdownStyle, MarkdownStyleSpan } from "./ir.js";
/** Opening/closing marker pair used when rendering one Markdown style span. */
export type RenderStyleMarker = {
open: string | ((span: MarkdownStyleSpan) => string);
close: string;
};
/** Optional marker overrides keyed by Markdown style. */
export type RenderStyleMap = Partial<Record<MarkdownStyle, RenderStyleMarker>>;
/** Rendered link wrapper coordinates and markers returned by link builders. */
export type RenderLink = {
start: number;
end: number;
@@ -14,6 +17,7 @@ export type RenderLink = {
close: string;
};
/** Rendering hooks for escaping text, styles, and optional link wrappers. */
export type RenderOptions = {
styleMarkers: RenderStyleMap;
escapeText: (text: string) => string;
@@ -46,6 +50,7 @@ function sortStyleSpans(spans: MarkdownStyleSpan[]): MarkdownStyleSpan[] {
});
}
/** Renders Markdown IR by applying caller-provided style/link markers. */
export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions): string {
const text = ir.text ?? "";
if (!text) {
@@ -104,7 +109,7 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
}
const points = [...boundaries].toSorted((a, b) => a - b);
// Unified stack for both styles and links, tracking close string and end position
// Links and styles share one stack so overlapping spans close in one LIFO order.
const stack: { close: string; end: number }[] = [];
type OpeningItem =
| { end: number; open: string; close: string; kind: "link"; index: number }
@@ -121,7 +126,7 @@ export function renderMarkdownWithMarkers(ir: MarkdownIR, options: RenderOptions
for (let i = 0; i < points.length; i += 1) {
const pos = points[i];
// Close ALL elements (styles and links) in LIFO order at this position
// Close every element ending here before opening new same-position spans.
while (stack.length && stack[stack.length - 1]?.end === pos) {
const item = stack.pop();
if (item) {

View File

@@ -10,11 +10,12 @@ const MARKDOWN_STYLE_MARKERS = {
code_block: { open: "```\n", close: "```" },
} as const;
/** Converts markdown tables into the configured plaintext/code rendering mode. */
/** Converts markdown tables into the configured plaintext/code mode while preserving links. */
export function convertMarkdownTables(markdown: string, mode: MarkdownTableMode): string {
if (!markdown || mode === "off") {
return markdown;
}
// External "block" mode shares the code renderer when callers want inline replacement text.
const effectiveMode = mode === "block" ? "code" : mode;
const { ir, hasTables } = markdownToIRWithMeta(markdown, {
linkify: false,

View File

@@ -1 +1,2 @@
/** Table rendering modes shared by markdown parsing and table conversion helpers. */
export type MarkdownTableMode = "off" | "bullets" | "code" | "block";

View File

@@ -50,6 +50,7 @@ export function asSafeIntegerInRange(
return value;
}
/** Normalizes numeric string tokens while rejecting whitespace-only input. */
function normalizeNumericString(value: string): string | undefined {
const trimmed = value.trim();
return trimmed ? trimmed : undefined;
@@ -366,6 +367,8 @@ export function resolveExpiresAtMsFromDurationOrEpoch(
return resolveExpiresAtMsFromDurationSeconds(parsed, { nowMs: opts.nowMs });
}
const absoluteMillisecondsThreshold = opts.absoluteMillisecondsThreshold ?? 1_000_000_000_000;
// Values below this threshold are treated as epoch seconds; larger values are
// already millisecond timestamps and must fit JavaScript Date bounds.
if (parsed < absoluteMillisecondsThreshold) {
return resolveExpiresAtMsFromEpochSeconds(parsed);
}

View File

@@ -41,6 +41,11 @@ const INCLUDE_RAW = parseBooleanEnv({
name: "OPENCLAW_PROMPT_INCLUDE_RAW",
raw: process.env.OPENCLAW_PROMPT_INCLUDE_RAW,
});
const KEEP_TMP = parseBooleanEnv({
fallback: false,
name: "OPENCLAW_PROMPT_KEEP_TMP",
raw: process.env.OPENCLAW_PROMPT_KEEP_TMP,
});
const CLAUDE_BIN = process.env.CLAUDE_BIN?.trim() || "claude";
const NODE_BIN = process.env.OPENCLAW_NODE_BIN?.trim() || process.execPath;
const TIMEOUT_MS = parseStrictIntegerOption({
@@ -88,7 +93,7 @@ type PromptResult = {
error?: string;
matchedExtraUsage400: boolean;
capture?: CaptureSummary;
tmpDir: string;
tmpDir?: string;
};
type ProxyCapture = {
@@ -106,6 +111,17 @@ type TokenSource = {
token: string;
};
type StoppableGatewayChild = {
exitCode: number | null;
signalCode: NodeJS.Signals | null;
kill(signal: NodeJS.Signals): boolean;
once(event: "exit", listener: () => void): unknown;
};
type ClosableLogFile = {
close(): Promise<void>;
};
function toHeaderValue(value: string | string[] | undefined): string | undefined {
return Array.isArray(value) ? value.join(", ") : value;
}
@@ -168,6 +184,17 @@ function matchesExtraUsage400(...parts: Array<string | undefined>): boolean {
.includes("third-party apps now draw from your extra usage");
}
function promptProbeTmpResult(tmpDir: string, keepTmp = KEEP_TMP): Pick<PromptResult, "tmpDir"> {
return keepTmp ? { tmpDir } : {};
}
async function cleanupPromptProbeTmpDir(tmpDir: string, keepTmp = KEEP_TMP): Promise<void> {
if (keepTmp) {
return;
}
await fs.rm(tmpDir, { force: true, recursive: true });
}
function isSetupToken(value: string): boolean {
return value.startsWith("sk-ant-oat01-");
}
@@ -411,45 +438,50 @@ async function runDirectPrompt(prompt: string): Promise<PromptResult> {
? await startAnthropicProxy({ port: proxyPort, upstreamBaseUrl: "https://api.anthropic.com" })
: undefined;
const stdout: string[] = [];
const stderr: string[] = [];
const child = spawn(CLAUDE_BIN, [...DIRECT_CLAUDE_ARGS, prompt, USER_PROMPT], {
cwd: process.cwd(),
env: {
...process.env,
...(proxyPort ? { ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}` } : {}),
ANTHROPIC_API_KEY: "",
ANTHROPIC_API_KEY_OLD: "",
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => stdout.push(String(chunk)));
child.stderr.on("data", (chunk) => stderr.push(String(chunk)));
const exit = await withTimeout(
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve) => {
child.once("exit", (code, signal) => resolve({ code, signal }));
}),
TIMEOUT_MS,
() => {
child.kill("SIGKILL");
return { code: null, signal: "SIGKILL" as NodeJS.Signals };
},
);
await proxy?.stop().catch(() => {});
const joinedStdout = stdout.join("");
const joinedStderr = stderr.join("");
return {
prompt,
ok: exit.code === 0 && !matchesExtraUsage400(joinedStdout, joinedStderr),
transport: "direct",
exitCode: exit.code,
signal: exit.signal,
stdout: redactForDevToolLog(joinedStdout.trim()) || undefined,
stderr: redactForDevToolLog(joinedStderr.trim()) || undefined,
matchedExtraUsage400: matchesExtraUsage400(joinedStdout, joinedStderr),
capture: summarizeCapture(proxy?.getLastCapture(), prompt),
tmpDir,
};
try {
const stdout: string[] = [];
const stderr: string[] = [];
const child = spawn(CLAUDE_BIN, [...DIRECT_CLAUDE_ARGS, prompt, USER_PROMPT], {
cwd: process.cwd(),
env: {
...process.env,
...(proxyPort ? { ANTHROPIC_BASE_URL: `http://127.0.0.1:${proxyPort}` } : {}),
ANTHROPIC_API_KEY: "",
ANTHROPIC_API_KEY_OLD: "",
},
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk) => stdout.push(String(chunk)));
child.stderr.on("data", (chunk) => stderr.push(String(chunk)));
const exit = await withTimeout(
new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.once("error", reject);
child.once("exit", (code, signal) => resolve({ code, signal }));
}),
TIMEOUT_MS,
() => {
child.kill("SIGKILL");
return { code: null, signal: "SIGKILL" as NodeJS.Signals };
},
);
const joinedStdout = stdout.join("");
const joinedStderr = stderr.join("");
return {
prompt,
ok: exit.code === 0 && !matchesExtraUsage400(joinedStdout, joinedStderr),
transport: "direct",
exitCode: exit.code,
signal: exit.signal,
stdout: redactForDevToolLog(joinedStdout.trim()) || undefined,
stderr: redactForDevToolLog(joinedStderr.trim()) || undefined,
matchedExtraUsage400: matchesExtraUsage400(joinedStdout, joinedStderr),
capture: summarizeCapture(proxy?.getLastCapture(), prompt),
...promptProbeTmpResult(tmpDir),
};
} finally {
await proxy?.stop().catch(() => {});
await cleanupPromptProbeTmpDir(tmpDir).catch(() => {});
}
}
async function startGatewayProcess(params: {
@@ -490,25 +522,47 @@ async function startGatewayProcess(params: {
child.stdout.on("data", (chunk) => void logFile.appendFile(chunk));
child.stderr.on("data", (chunk) => void logFile.appendFile(chunk));
return {
async stop() {
if (!child.killed) {
child.kill("SIGINT");
}
const exited = await withTimeout(
new Promise<boolean>((resolve) => {
child.once("exit", () => resolve(true));
}),
1_500,
() => false,
);
if (!exited && !child.killed) {
child.kill("SIGKILL");
}
await logFile.close();
async stop(): Promise<boolean> {
return await stopGatewayPromptChild(child, logFile);
},
};
}
async function stopGatewayPromptChild(
child: StoppableGatewayChild,
logFile: ClosableLogFile,
sigintTimeoutMs = 1_500,
sigkillTimeoutMs = 1_500,
): Promise<boolean> {
let exited = child.exitCode !== null || child.signalCode !== null;
const exitPromise = exited
? Promise.resolve()
: new Promise<void>((resolve) => {
child.once("exit", () => {
exited = true;
resolve();
});
});
if (!exited) {
child.kill("SIGINT");
}
const exitedAfterSigint = await withTimeout(
exitPromise.then(() => true),
sigintTimeoutMs,
() => false,
);
if (!exitedAfterSigint && !exited) {
child.kill("SIGKILL");
await withTimeout(
exitPromise.then(() => true),
sigkillTimeoutMs,
() => false,
);
}
await logFile.close();
return exited;
}
async function waitForGatewayReady(url: string, token: string): Promise<void> {
const deadline = Date.now() + 45_000;
let lastError = "gateway start timeout";
@@ -544,80 +598,81 @@ async function runGatewayPrompt(prompt: string): Promise<PromptResult> {
ENABLE_CAPTURE && proxyPort
? await startAnthropicProxy({ port: proxyPort, upstreamBaseUrl: "https://api.anthropic.com" })
: undefined;
let gateway: Awaited<ReturnType<typeof startGatewayProcess>> | undefined;
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(bundledPluginsDir, { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
gateway: {
mode: "local",
controlUi: { enabled: false },
tailscale: { mode: "off" },
},
discovery: {
mdns: { mode: "off" },
wideArea: { enabled: false },
},
...(proxyPort
? {
models: {
providers: {
anthropic: {
baseUrl: `http://127.0.0.1:${proxyPort}`,
api: "anthropic-messages",
models: [],
try {
await fs.mkdir(agentDir, { recursive: true });
await fs.mkdir(bundledPluginsDir, { recursive: true });
await fs.writeFile(
configPath,
`${JSON.stringify(
{
gateway: {
mode: "local",
controlUi: { enabled: false },
tailscale: { mode: "off" },
},
discovery: {
mdns: { mode: "off" },
wideArea: { enabled: false },
},
...(proxyPort
? {
models: {
providers: {
anthropic: {
baseUrl: `http://127.0.0.1:${proxyPort}`,
api: "anthropic-messages",
models: [],
},
},
},
}
: {}),
auth: {
profiles: { [tokenSource.profileId]: { provider: "anthropic", mode: "token" } },
order: { anthropic: [tokenSource.profileId] },
},
agents: {
defaults: {
model: "anthropic/claude-sonnet-4-6",
heartbeat: {
includeSystemPromptSection: false,
},
}
: {}),
auth: {
profiles: { [tokenSource.profileId]: { provider: "anthropic", mode: "token" } },
order: { anthropic: [tokenSource.profileId] },
},
agents: {
defaults: {
model: "anthropic/claude-sonnet-4-6",
heartbeat: {
includeSystemPromptSection: false,
},
},
},
},
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: 1,
profiles: {
[tokenSource.profileId]: {
type: "token",
provider: "anthropic",
token: tokenSource.token,
null,
2,
)}\n`,
);
await fs.writeFile(
path.join(agentDir, "auth-profiles.json"),
`${JSON.stringify(
{
version: 1,
profiles: {
[tokenSource.profileId]: {
type: "token",
provider: "anthropic",
token: tokenSource.token,
},
},
},
},
null,
2,
)}\n`,
);
null,
2,
)}\n`,
);
const gateway = await startGatewayProcess({
port,
gatewayToken,
configPath,
stateDir,
agentDir,
bundledPluginsDir,
logPath,
});
try {
gateway = await startGatewayProcess({
port,
gatewayToken,
configPath,
stateDir,
agentDir,
bundledPluginsDir,
logPath,
});
const url = `ws://127.0.0.1:${port}`;
await waitForGatewayReady(url, gatewayToken);
const agentRes = await callGateway({
@@ -644,7 +699,7 @@ async function runGatewayPrompt(prompt: string): Promise<PromptResult> {
error: redactForDevToolLog(`missing runId: ${JSON.stringify(agentRes)}`),
matchedExtraUsage400: false,
capture: summarizeCapture(proxy?.getLastCapture(), prompt),
tmpDir,
...promptProbeTmpResult(tmpDir),
};
}
const waitRes = await callGateway({
@@ -672,11 +727,14 @@ async function runGatewayPrompt(prompt: string): Promise<PromptResult> {
: redactForDevToolLog(waitRes.error || logTail || "agent.wait failed"),
matchedExtraUsage400: matched400,
capture: summarizeCapture(proxy?.getLastCapture(), prompt),
tmpDir,
...promptProbeTmpResult(tmpDir),
};
} finally {
await gateway.stop().catch(() => {});
const gatewayStopped = (await gateway?.stop().catch(() => false)) ?? true;
await proxy?.stop().catch(() => {});
if (gatewayStopped) {
await cleanupPromptProbeTmpDir(tmpDir).catch(() => {});
}
}
}
@@ -706,8 +764,11 @@ async function main() {
}
export const testing = {
cleanupPromptProbeTmpDir,
matchesExtraUsage400,
promptProbeTmpResult,
resolveAnthropicUpstreamUrl,
stopGatewayPromptChild,
summarizeCapture,
summarizeText,
};

View File

@@ -1,4 +1,12 @@
import { accessSync, chmodSync, constants, existsSync, mkdtempSync, writeFileSync } from "node:fs";
import {
accessSync,
chmodSync,
constants,
existsSync,
mkdtempSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { performance } from "node:perf_hooks";
@@ -36,6 +44,7 @@ const LINTABLE_CORE_PATH_RE = /^(?:src|ui|packages)\/.+\.[cm]?[jt]sx?$/u;
const CORE_LINT_OPTIMIZATION_NEUTRAL_PATH_RE =
/^(?:scripts|test\/scripts)\/|^\.github\/workflows\/ci\.yml$/u;
let corepackPnpmShimDir;
let corepackPnpmShimCleanupRegistered = false;
export function createChangedCheckChildEnv(baseEnv = process.env) {
const resolvedBaseEnv = resolveLocalHeavyCheckEnv(baseEnv);
@@ -464,9 +473,27 @@ function ensureCorepackPnpmShimDir() {
chmodSync(pnpmPath, 0o755);
writeFileSync(path.join(dir, "pnpm.cmd"), "@echo off\r\ncorepack pnpm %*\r\n", "utf8");
corepackPnpmShimDir = dir;
registerCorepackPnpmShimCleanup();
return dir;
}
function registerCorepackPnpmShimCleanup() {
if (corepackPnpmShimCleanupRegistered) {
return;
}
corepackPnpmShimCleanupRegistered = true;
process.once("exit", cleanupCorepackPnpmShimDir);
}
export function cleanupCorepackPnpmShimDir() {
if (!corepackPnpmShimDir) {
return;
}
const dir = corepackPnpmShimDir;
corepackPnpmShimDir = undefined;
rmSync(dir, { recursive: true, force: true });
}
async function runCommand(command, timings) {
const startedAt = performance.now();
console.error(`\n[check:changed] ${command.name}`);

View File

@@ -7,6 +7,8 @@ import {
} from "./deadcode-unused-files.allowlist.mjs";
const KNIP_VERSION = "6.8.0";
export const KNIP_TIMEOUT_MS = 10 * 60 * 1000;
export const KNIP_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
const KNIP_ARGS = [
"--config",
"config/knip.config.ts",
@@ -108,18 +110,28 @@ export function formatUnusedFileComparison(comparison) {
return lines.join("\n");
}
export function runKnipUnusedFiles() {
const result = spawnSync(
function spawnErrorCode(error) {
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
}
export function runKnipUnusedFiles(params = {}) {
const run = params.spawnSyncCommand ?? spawnSync;
const result = run(
"pnpm",
["--config.minimum-release-age=0", "dlx", `knip@${KNIP_VERSION}`, ...KNIP_ARGS],
{
encoding: "utf8",
killSignal: "SIGTERM",
maxBuffer: params.maxBufferBytes ?? KNIP_MAX_BUFFER_BYTES,
stdio: ["ignore", "pipe", "pipe"],
timeout: params.timeoutMs ?? KNIP_TIMEOUT_MS,
},
);
return {
status: result.status,
signal: result.signal,
errorCode: spawnErrorCode(result.error),
errorMessage: result.error?.message,
output: `${result.stdout ?? ""}${result.stderr ?? ""}`,
};
}
@@ -144,6 +156,18 @@ export function checkUnusedFiles(
function main() {
const result = runKnipUnusedFiles();
if (result.errorCode || result.status === null) {
console.error(
`deadcode unused-file scan failed: ${result.errorCode ?? result.signal ?? "unknown"}${
result.errorMessage ? `: ${result.errorMessage}` : ""
}`,
);
if (result.output) {
console.error(result.output);
}
process.exitCode = 1;
return;
}
const check = checkUnusedFiles(result.output);
if (!check.ok) {
if (check.message) {

View File

@@ -137,9 +137,22 @@ function runStep(name, command, args, options = {}, params = {}) {
stdio: "inherit",
...options,
});
const status = result.status ?? (result.signal ? 1 : 0);
console.error(`[gateway-cpu] ${status === 0 ? "pass" : "fail"} ${name}`);
return { name, status, signal: result.signal ?? null };
const error =
result.error instanceof Error
? result.error.message
: result.error
? String(result.error)
: null;
const status = result.error ? 1 : (result.status ?? (result.signal ? 1 : 0));
console.error(
`[gateway-cpu] ${status === 0 ? "pass" : "fail"} ${name}${error ? `: ${error}` : ""}`,
);
return {
name,
status,
signal: result.signal ?? null,
...(error ? { error } : {}),
};
}
function pnpmCommand(args, params = {}) {

View File

@@ -404,7 +404,7 @@ async function allocateLoopbackPort() {
const { port } = address;
server.close((closeErr) => {
if (closeErr) {
reject(closeErr);
reject(closeErr instanceof Error ? closeErr : new Error(String(closeErr)));
return;
}
resolve(port);
@@ -484,100 +484,142 @@ function parseTimingFile(timeFilePath) {
};
}
async function runTimedWatch(options, outputDir) {
export async function runTimedWatch(options, outputDir, deps = {}) {
const allocatePort = deps.allocateLoopbackPort ?? allocateLoopbackPort;
const parseTiming = deps.parseTimingFile ?? parseTimingFile;
const readCpuMs = deps.readProcessTreeCpuMs ?? readProcessTreeCpuMs;
const sleepMs = deps.sleep ?? sleep;
const spawnCommand = deps.spawn ?? spawn;
const stopChild = deps.stopTimedWatchChild ?? stopTimedWatchChild;
const waitReady = deps.waitForGatewayReady ?? waitForGatewayReady;
const pidFilePath = path.join(outputDir, "watch.pid");
const timeFilePath = path.join(outputDir, "watch.time.log");
const isolatedHomeDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-gateway-watch-"));
fs.writeFileSync(path.join(outputDir, "watch.home.txt"), `${isolatedHomeDir}\n`, "utf8");
const stdoutPath = path.join(outputDir, "watch.stdout.log");
const stderrPath = path.join(outputDir, "watch.stderr.log");
for (const stalePath of [pidFilePath, timeFilePath, stdoutPath, stderrPath]) {
removePathIfExists(stalePath);
}
const port = await allocateLoopbackPort();
fs.writeFileSync(path.join(outputDir, "watch.port.txt"), `${String(port)}\n`, "utf8");
const { command, args, env } = buildTimedWatchCommand(
pidFilePath,
timeFilePath,
isolatedHomeDir,
port,
);
const child = spawn(command, args, {
cwd: process.cwd(),
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stdoutTruncated = false;
let stderrTruncated = false;
let buildDetection = { buffer: "", triggered: false, reason: null };
child.stdout?.on("data", (chunk) => {
const next = appendBoundedWatchLog(stdout, chunk);
stdout = next.text;
stdoutTruncated ||= next.truncated;
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
});
child.stderr?.on("data", (chunk) => {
const next = appendBoundedWatchLog(stderr, chunk);
stderr = next.text;
stderrTruncated ||= next.truncated;
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
});
let spawnError = null;
const spawnErrorExit = new Promise((resolve) => {
child.once("error", (error) => {
spawnError = error;
resolve({ code: null, signal: null, error: error.message });
});
});
let watchPid = null;
for (let attempt = 0; attempt < 50; attempt += 1) {
if (fs.existsSync(pidFilePath)) {
watchPid = Number(fs.readFileSync(pidFilePath, "utf8").trim());
break;
try {
const stdoutPath = path.join(outputDir, "watch.stdout.log");
const stderrPath = path.join(outputDir, "watch.stderr.log");
for (const stalePath of [pidFilePath, timeFilePath, stdoutPath, stderrPath]) {
removePathIfExists(stalePath);
}
await sleep(100);
const port = await allocatePort();
fs.writeFileSync(path.join(outputDir, "watch.port.txt"), `${String(port)}\n`, "utf8");
const { command, args, env } = buildTimedWatchCommand(
pidFilePath,
timeFilePath,
isolatedHomeDir,
port,
);
const child = spawnCommand(command, args, {
cwd: process.cwd(),
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
let stdout = "";
let stderr = "";
let stdoutTruncated = false;
let stderrTruncated = false;
let buildDetection = { buffer: "", triggered: false, reason: null };
child.stdout?.on("data", (chunk) => {
const next = appendBoundedWatchLog(stdout, chunk);
stdout = next.text;
stdoutTruncated ||= next.truncated;
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
});
child.stderr?.on("data", (chunk) => {
const next = appendBoundedWatchLog(stderr, chunk);
stderr = next.text;
stderrTruncated ||= next.truncated;
buildDetection = updateWatchBuildDetection(buildDetection, chunk);
});
let spawnError = null;
const spawnErrorExit = new Promise((resolve) => {
child.once("error", (error) => {
spawnError = error;
resolve({ code: null, signal: null, error: error.message });
});
});
const raceSpawnError = async (operation) =>
await Promise.race([
Promise.resolve(operation).then((value) => ({ type: "value", value })),
spawnErrorExit.then((value) => ({ type: "spawn-error", value })),
]);
let watchPid = null;
let exit = null;
for (let attempt = 0; attempt < 50; attempt += 1) {
if (fs.existsSync(pidFilePath)) {
watchPid = Number(fs.readFileSync(pidFilePath, "utf8").trim());
break;
}
const waitResult = await raceSpawnError(sleepMs(100));
if (waitResult.type === "spawn-error") {
exit = waitResult.value;
break;
}
}
let readyBeforeWindow = false;
let idleCpuStartMs = null;
let idleCpuEndMs = null;
if (!exit) {
const readyResult = await raceSpawnError(
waitReady(() => `${stdout}\n${stderr}`, options.readyTimeoutMs),
);
if (readyResult.type === "spawn-error") {
exit = readyResult.value;
} else {
readyBeforeWindow = readyResult.value;
}
}
if (!exit && readyBeforeWindow && options.readySettleMs > 0) {
const settleResult = await raceSpawnError(sleepMs(options.readySettleMs));
if (settleResult.type === "spawn-error") {
exit = settleResult.value;
}
}
if (!exit) {
idleCpuStartMs = watchPid ? readCpuMs(watchPid) : null;
const windowResult = await raceSpawnError(sleepMs(options.windowMs));
if (windowResult.type === "spawn-error") {
exit = windowResult.value;
} else {
idleCpuEndMs = watchPid ? readCpuMs(watchPid) : null;
}
}
if (!exit) {
const stopResult = await raceSpawnError(stopChild(child, watchPid, options));
exit = stopResult.value;
}
fs.writeFileSync(stdoutPath, formatCapturedWatchLog(stdout, stdoutTruncated), "utf8");
fs.writeFileSync(stderrPath, formatCapturedWatchLog(stderr, stderrTruncated), "utf8");
const timingFileMissing = !fs.existsSync(timeFilePath);
const timing = timingFileMissing
? { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN }
: parseTiming(timeFilePath);
return {
exit,
spawnError: spawnError ? spawnError.message : null,
timingFileMissing,
timing,
readyBeforeWindow,
idleCpuMs:
idleCpuStartMs == null || idleCpuEndMs == null
? null
: Math.max(0, idleCpuEndMs - idleCpuStartMs),
stdoutPath,
stderrPath,
timeFilePath,
watchTriggeredBuild: buildDetection.triggered,
watchBuildReason: buildDetection.reason,
};
} finally {
fs.rmSync(isolatedHomeDir, { force: true, recursive: true });
}
const readyBeforeWindow = await waitForGatewayReady(
() => `${stdout}\n${stderr}`,
options.readyTimeoutMs,
);
if (readyBeforeWindow && options.readySettleMs > 0) {
await sleep(options.readySettleMs);
}
const idleCpuStartMs = watchPid ? readProcessTreeCpuMs(watchPid) : null;
await sleep(options.windowMs);
const idleCpuEndMs = watchPid ? readProcessTreeCpuMs(watchPid) : null;
const exit = await Promise.race([stopTimedWatchChild(child, watchPid, options), spawnErrorExit]);
fs.writeFileSync(stdoutPath, formatCapturedWatchLog(stdout, stdoutTruncated), "utf8");
fs.writeFileSync(stderrPath, formatCapturedWatchLog(stderr, stderrTruncated), "utf8");
const timingFileMissing = !fs.existsSync(timeFilePath);
const timing = timingFileMissing
? { userSeconds: Number.NaN, sysSeconds: Number.NaN, elapsedSeconds: Number.NaN }
: parseTimingFile(timeFilePath);
return {
exit,
spawnError: spawnError ? spawnError.message : null,
timingFileMissing,
timing,
readyBeforeWindow,
idleCpuMs:
idleCpuStartMs == null || idleCpuEndMs == null
? null
: Math.max(0, idleCpuEndMs - idleCpuStartMs),
stdoutPath,
stderrPath,
timeFilePath,
watchTriggeredBuild: buildDetection.triggered,
watchBuildReason: buildDetection.reason,
};
}
export async function stopTimedWatchChild(child, watchPid, options, deps = {}) {

View File

@@ -2,6 +2,53 @@
import { execFileSync } from "node:child_process";
const DEFAULT_GITHUB_REPOSITORY = "openclaw/openclaw";
const RUN_JOBS_PAGE_SIZE = 20;
const RUN_JOBS_MAX_PAGES = 25;
const GH_JSON_RETRY_DELAYS_MS = [1_000, 3_000, 6_000];
function sleepSync(ms) {
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
}
function parseJsonCommand(command, args, options = {}) {
let lastError;
for (let attempt = 0; attempt <= GH_JSON_RETRY_DELAYS_MS.length; attempt += 1) {
try {
return JSON.parse(
execFileSync(command, args, {
encoding: "utf8",
...options,
}),
);
} catch (error) {
lastError = error;
const message = error instanceof Error ? error.message : String(error);
const retryable = /HTTP 5\d\d|Server Error|ETIMEDOUT|ECONNRESET|EAI_AGAIN/u.test(message);
if (!retryable || attempt === GH_JSON_RETRY_DELAYS_MS.length) {
throw error;
}
sleepSync(GH_JSON_RETRY_DELAYS_MS[attempt]);
}
}
throw lastError;
}
function normalizeRunJob(job) {
return {
completedAt: job.completedAt ?? job.completed_at ?? null,
conclusion: job.conclusion ?? "",
databaseId: job.databaseId ?? job.id,
name: job.name,
startedAt: job.startedAt ?? job.started_at ?? null,
status: job.status ?? "",
};
}
export function collectRunJobsFromPages(pages) {
return pages.flatMap((page) => (Array.isArray(page.jobs) ? page.jobs.map(normalizeRunJob) : []));
}
function parseTime(value) {
if (!value || value === "0001-01-01T00:00:00Z") {
return null;
@@ -216,15 +263,37 @@ function listRecentSuccessfulCiRuns(limit) {
}
function loadRun(runId) {
return JSON.parse(
execFileSync(
"gh",
["run", "view", runId, "--json", "status,conclusion,createdAt,updatedAt,jobs"],
{
encoding: "utf8",
},
),
);
const run = parseJsonCommand("gh", [
"run",
"view",
runId,
"--json",
"status,conclusion,createdAt,updatedAt",
]);
const repository = process.env.GITHUB_REPOSITORY || DEFAULT_GITHUB_REPOSITORY;
const pages = [];
let totalCount = null;
for (let page = 1; page <= RUN_JOBS_MAX_PAGES; page += 1) {
const payload = parseJsonCommand("gh", [
"api",
"-X",
"GET",
`repos/${repository}/actions/runs/${runId}/jobs?per_page=${RUN_JOBS_PAGE_SIZE}&page=${page}`,
]);
pages.push(payload);
const jobs = Array.isArray(payload.jobs) ? payload.jobs : [];
totalCount = typeof payload.total_count === "number" ? payload.total_count : totalCount;
if (
jobs.length === 0 ||
(totalCount !== null && collectRunJobsFromPages(pages).length >= totalCount)
) {
break;
}
}
return {
...run,
jobs: collectRunJobsFromPages(pages),
};
}
function summarizeJobs(run) {

View File

@@ -51,6 +51,10 @@ type TelegramFlowResult = {
previewUpdates: number;
};
function toError(value: unknown): Error {
return value instanceof Error ? value : new Error(String(value));
}
type TelegramThinkingFinalDeps = {
createDraftStream?: (params: {
accountId?: string;
@@ -310,15 +314,35 @@ export async function runTelegramThinkingFinalFlow(
});
const wait = deps.sleep ?? sleep;
for (const update of thinkingUpdates) {
stream.update(formatReasoningMessage(update));
await stream.flush();
if (delayMs > 0) {
await wait(delayMs);
let previewStarted = false;
let flowError: unknown;
try {
for (const update of thinkingUpdates) {
previewStarted = true;
stream.update(formatReasoningMessage(update));
await stream.flush();
if (delayMs > 0) {
await wait(delayMs);
}
}
} catch (error) {
flowError = error;
}
let cleanupError: unknown;
if (previewStarted) {
try {
await stream.clear();
} catch (error) {
cleanupError = error;
}
}
if (flowError) {
throw toError(flowError);
}
if (cleanupError) {
throw toError(cleanupError);
}
await stream.clear();
const final = await (deps.sendFinal ?? sendTelegramFinal)({
accountId: options.accountId,
cfg: options.cfg,
@@ -350,19 +374,39 @@ export async function runTelegramWorkingFinalFlow(
let previewUpdates = 0;
let lastPreviewText = "";
const updateIntervalMs = delayMs > 0 ? delayMs : 1_000;
for (let elapsedMs = 0; elapsedMs < durationMs; elapsedMs += updateIntervalMs) {
const previewText = formatWorkingProgressPreview(elapsedMs);
if (previewText !== lastPreviewText) {
await draft.update(previewText);
lastPreviewText = previewText;
previewUpdates += 1;
let draftStarted = false;
let flowError: unknown;
try {
for (let elapsedMs = 0; elapsedMs < durationMs; elapsedMs += updateIntervalMs) {
const previewText = formatWorkingProgressPreview(elapsedMs);
if (previewText !== lastPreviewText) {
draftStarted = true;
await draft.update(previewText);
lastPreviewText = previewText;
previewUpdates += 1;
}
if (delayMs > 0 && elapsedMs + updateIntervalMs < durationMs) {
await wait(delayMs);
}
}
if (delayMs > 0 && elapsedMs + updateIntervalMs < durationMs) {
await wait(delayMs);
} catch (error) {
flowError = error;
}
let cleanupError: unknown;
if (draftStarted) {
try {
draft.stop();
} catch (error) {
cleanupError = error;
}
}
if (flowError) {
throw toError(flowError);
}
if (cleanupError) {
throw toError(cleanupError);
}
draft.stop();
const final = await (deps.sendFinal ?? sendTelegramFinal)({
accountId: options.accountId,
cfg: options.cfg,

View File

@@ -1,3 +1,4 @@
import { fileURLToPath } from "node:url";
import {
MIN_CLIENT_PROTOCOL_VERSION,
PROTOCOL_VERSION,
@@ -12,69 +13,94 @@ function writeStderrLine(message: string): void {
process.stderr.write(`${message}\n`);
}
const { get: getArg } = createArgReader();
const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL;
const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN;
if (!urlRaw || !token) {
function writeUsage(): void {
writeStderrLine(
"Usage: bun scripts/dev/gateway-smoke.ts --url <wss://host[:port]> --token <gateway.auth.token>\n" +
"Or set env: OPENCLAW_GATEWAY_URL / OPENCLAW_GATEWAY_TOKEN",
);
process.exit(1);
}
async function main() {
const url = resolveGatewayUrl(urlRaw);
const { request, waitOpen, close } = createGatewayWsClient({
type GatewaySmokeClient = ReturnType<typeof createGatewayWsClient>;
type GatewaySmokeDeps = {
createClient?: typeof createGatewayWsClient;
stderr?: (message: string) => void;
stdout?: (message: string) => void;
};
export async function runGatewaySmoke(
input: { token: string; urlRaw: string },
deps: GatewaySmokeDeps = {},
): Promise<number> {
const url = resolveGatewayUrl(input.urlRaw);
const createClient = deps.createClient ?? createGatewayWsClient;
const stderr = deps.stderr ?? writeStderrLine;
const stdout = deps.stdout ?? writeStdoutLine;
const client: GatewaySmokeClient = createClient({
url: url.toString(),
onEvent: (evt) => {
// Ignore noisy connect handshakes.
void evt;
},
});
const { request, waitOpen, close } = client;
await waitOpen();
try {
await waitOpen();
// Match iOS "operator" session defaults: token auth, no device identity.
const connectRes = await request("connect", {
minProtocol: MIN_CLIENT_PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "openclaw-ios",
displayName: "openclaw gateway smoke test",
version: "dev",
platform: "dev",
mode: "ui",
instanceId: "openclaw-dev-smoke",
},
locale: "en-US",
userAgent: "gateway-smoke",
role: "operator",
scopes: ["operator.read", "operator.write", "operator.admin"],
caps: [],
auth: { token },
});
// Match iOS "operator" session defaults: token auth, no device identity.
const connectRes = await request("connect", {
minProtocol: MIN_CLIENT_PROTOCOL_VERSION,
maxProtocol: PROTOCOL_VERSION,
client: {
id: "openclaw-ios",
displayName: "openclaw gateway smoke test",
version: "dev",
platform: "dev",
mode: "ui",
instanceId: "openclaw-dev-smoke",
},
locale: "en-US",
userAgent: "gateway-smoke",
role: "operator",
scopes: ["operator.read", "operator.write", "operator.admin"],
caps: [],
auth: { token: input.token },
});
if (!connectRes.ok) {
writeStderrLine(`connect failed: ${String(connectRes.error)}`);
process.exit(2);
if (!connectRes.ok) {
stderr(`connect failed: ${String(connectRes.error)}`);
return 2;
}
const healthRes = await request("health");
if (!healthRes.ok) {
stderr(`health failed: ${String(healthRes.error)}`);
return 3;
}
const historyRes = await request("chat.history", { sessionKey: "main" }, 15000);
if (!historyRes.ok) {
stderr(`chat.history failed: ${String(historyRes.error)}`);
return 4;
}
stdout("ok: connected + health + chat.history");
return 0;
} finally {
close();
}
const healthRes = await request("health");
if (!healthRes.ok) {
writeStderrLine(`health failed: ${String(healthRes.error)}`);
process.exit(3);
}
const historyRes = await request("chat.history", { sessionKey: "main" }, 15000);
if (!historyRes.ok) {
writeStderrLine(`chat.history failed: ${String(historyRes.error)}`);
process.exit(4);
}
writeStdoutLine("ok: connected + health + chat.history");
close();
}
await main();
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
const { get: getArg } = createArgReader();
const urlRaw = getArg("--url") ?? process.env.OPENCLAW_GATEWAY_URL;
const token = getArg("--token") ?? process.env.OPENCLAW_GATEWAY_TOKEN;
if (!urlRaw || !token) {
writeUsage();
process.exitCode = 1;
} else {
process.exitCode = await runGatewaySmoke({ token, urlRaw });
}
}

View File

@@ -108,18 +108,26 @@ export function createGatewayWsClient(params: {
const waitOpen = () =>
new Promise<void>((resolve, reject) => {
const t = setTimeout(
() => reject(new Error("ws open timeout")),
params.openTimeoutMs ?? 8000,
);
ws.once("open", () => {
const cleanup = () => {
clearTimeout(t);
ws.off("open", onOpen);
ws.off("error", onError);
};
const onOpen = () => {
cleanup();
resolve();
});
ws.once("error", (err) => {
clearTimeout(t);
};
const onError = (err: Error) => {
cleanup();
reject(err instanceof Error ? err : new Error(String(err)));
});
};
const t = setTimeout(() => {
cleanup();
ws.terminate();
reject(new Error("ws open timeout"));
}, params.openTimeoutMs ?? 8000);
ws.once("open", onOpen);
ws.once("error", onError);
});
ws.on("message", (data) => {

View File

@@ -275,47 +275,66 @@ export function sanitizeDocsConfigForEnglishOnly(value) {
return Object.keys(sanitized).length > 0 ? sanitized : undefined;
}
function prepareMirroredDocsDir(sourceDir = DOCS_DIR) {
/**
* @param {string} [sourceDir]
* @param {{
* resolveClawHubRepoPathImpl?: typeof resolveClawHubRepoPath;
* syncClawHubDocsTreeImpl?: typeof syncClawHubDocsTree;
* }} [options]
*/
export function prepareMirroredDocsDir(sourceDir = DOCS_DIR, options = {}) {
const sourceRoot = path.resolve(sourceDir);
if (sourceRoot !== path.resolve(DOCS_DIR)) {
return { dir: sourceRoot, mirroredClawHub: false, cleanup: () => {} };
}
const clawhubRepo = resolveClawHubRepoPath("", { required: false });
const resolveClawHubRepoPathImpl = options.resolveClawHubRepoPathImpl ?? resolveClawHubRepoPath;
const syncClawHubDocsTreeImpl = options.syncClawHubDocsTreeImpl ?? syncClawHubDocsTree;
const clawhubRepo = resolveClawHubRepoPathImpl("", { required: false });
if (!clawhubRepo) {
return { dir: sourceRoot, mirroredClawHub: false, cleanup: () => {} };
}
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docs-link-audit-"));
fs.cpSync(sourceRoot, tempDir, { recursive: true });
syncClawHubDocsTree(tempDir, { repoPath: clawhubRepo, required: false });
return {
dir: tempDir,
mirroredClawHub: true,
cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),
};
try {
fs.cpSync(sourceRoot, tempDir, { recursive: true });
syncClawHubDocsTreeImpl(tempDir, { repoPath: clawhubRepo, required: false });
return {
dir: tempDir,
mirroredClawHub: true,
cleanup: () => fs.rmSync(tempDir, { recursive: true, force: true }),
};
} catch (error) {
fs.rmSync(tempDir, { recursive: true, force: true });
throw error;
}
}
export function prepareAnchorAuditDocsDir(sourceDir = DOCS_DIR) {
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docs-anchor-audit-"));
fs.cpSync(sourceDir, tempDir, { recursive: true });
try {
fs.cpSync(sourceDir, tempDir, { recursive: true });
for (const entry of fs.readdirSync(tempDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
for (const entry of fs.readdirSync(tempDir, { withFileTypes: true })) {
if (!entry.isDirectory()) {
continue;
}
if (!isGeneratedTranslatedDoc(`${entry.name}/`)) {
continue;
}
fs.rmSync(path.join(tempDir, entry.name), { recursive: true, force: true });
}
if (!isGeneratedTranslatedDoc(`${entry.name}/`)) {
continue;
}
fs.rmSync(path.join(tempDir, entry.name), { recursive: true, force: true });
const docsJsonPath = path.join(tempDir, "docs.json");
const docsConfig = JSON.parse(fs.readFileSync(docsJsonPath, "utf8"));
const sanitized = sanitizeDocsConfigForEnglishOnly(docsConfig);
fs.writeFileSync(docsJsonPath, `${JSON.stringify(sanitized, null, 2)}\n`, "utf8");
return tempDir;
} catch (error) {
fs.rmSync(tempDir, { recursive: true, force: true });
throw error;
}
const docsJsonPath = path.join(tempDir, "docs.json");
const docsConfig = JSON.parse(fs.readFileSync(docsJsonPath, "utf8"));
const sanitized = sanitizeDocsConfigForEnglishOnly(docsConfig);
fs.writeFileSync(docsJsonPath, `${JSON.stringify(sanitized, null, 2)}\n`, "utf8");
return tempDir;
}
/** @param {string} version */
@@ -528,6 +547,11 @@ export function auditDocsLinks(options = {}) {
* platform?: NodeJS.Platform;
* spawnSyncImpl?: typeof spawnSync;
* prepareAnchorAuditDocsDirImpl?: (sourceDir?: string) => string;
* prepareMirroredDocsDirImpl?: (sourceDir?: string) => {
* dir: string;
* mirroredClawHub: boolean;
* cleanup: () => void;
* };
* cleanupAnchorAuditDocsDirImpl?: (dir: string) => void;
* }} [options]
*/
@@ -540,10 +564,12 @@ export function runDocsLinkAuditCli(options = {}) {
const cleanupAnchorAuditDocsDirImpl =
options.cleanupAnchorAuditDocsDirImpl ??
((dir) => fs.rmSync(dir, { recursive: true, force: true }));
const mirroredDocsDir = prepareMirroredDocsDir(DOCS_DIR);
const anchorDocsDir = prepareAnchorAuditDocsDirImpl(mirroredDocsDir.dir);
const prepareMirroredDocsDirImpl = options.prepareMirroredDocsDirImpl ?? prepareMirroredDocsDir;
const mirroredDocsDir = prepareMirroredDocsDirImpl(DOCS_DIR);
let anchorDocsDir;
try {
anchorDocsDir = prepareAnchorAuditDocsDirImpl(mirroredDocsDir.dir);
// Use the npm Mintlify package explicitly. Some developer machines also
// have the Swift Package Manager tool named `mint` on PATH, and that
// binary exits with "command 'broken-links' not found".
@@ -565,7 +591,9 @@ export function runDocsLinkAuditCli(options = {}) {
return result.status ?? 1;
} finally {
cleanupAnchorAuditDocsDirImpl(anchorDocsDir);
if (anchorDocsDir) {
cleanupAnchorAuditDocsDirImpl(anchorDocsDir);
}
mirroredDocsDir.cleanup();
}
}

View File

@@ -43,6 +43,7 @@ const DEFAULT_PORT = 19000 + Math.floor(Math.random() * 1000);
const LOG_SCAN_CHUNK_BYTES = 64 * 1024;
const LOG_SCAN_MAX_LINE_CHARS = 16 * 1024;
const LOG_TAIL_BYTES = 256 * 1024;
const POSIX_PROCESS_SNAPSHOT_ARGS = ["-ww", "-axo", "pid=,ppid=,rss=,pcpu=,command="];
const ERROR_LOG_DENY_PATTERNS = [
/\buncaught exception\b/iu,
/\bunhandled rejection\b/iu,
@@ -1034,7 +1035,7 @@ async function samplePosixProcessWithDescendants(pid, run) {
return null;
}
try {
const { stdout } = await run("ps", ["-axo", "pid=,ppid=,rss=,pcpu=,command="], {
const { stdout } = await run("ps", POSIX_PROCESS_SNAPSHOT_ARGS, {
timeoutMs: 5000,
});
const rows = parsePosixProcessRows(stdout);
@@ -1054,7 +1055,7 @@ async function samplePosixProcessTree(pid, run, commandLineNeedles) {
return null;
}
try {
const { stdout } = await run("ps", ["-axo", "pid=,ppid=,rss=,pcpu=,command="], {
const { stdout } = await run("ps", POSIX_PROCESS_SNAPSHOT_ARGS, {
timeoutMs: 5000,
});
const rows = parsePosixProcessRows(stdout);

View File

@@ -15,6 +15,10 @@ const LOG_SCAN_BYTES = readPositiveInt(
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_LOG_SCAN_BYTES,
256 * 1024,
);
const GATEWAY_LOG_CAPTURE_BYTES = readPositiveInt(
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_GATEWAY_LOG_BYTES,
16 * 1024 * 1024,
);
const WATCHDOG_MS = readPositiveInt(process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_WATCHDOG_MS, 1000);
const READY_TIMEOUT_MS = readPositiveInt(
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_READY_MS,
@@ -33,14 +37,28 @@ const HTTP_PROBE_TIMEOUT_MS = readPositiveInt(
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_HTTP_MS,
5000,
);
const GATEWAY_TEARDOWN_GRACE_MS = readPositiveInt(
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_GRACE_MS,
10000,
);
const GATEWAY_TEARDOWN_KILL_GRACE_MS = readPositiveInt(
process.env.OPENCLAW_BUNDLED_PLUGIN_RUNTIME_TEARDOWN_KILL_GRACE_MS,
1000,
);
const GATEWAY_READY_LOG_NEEDLE = Buffer.from("[gateway] ready");
const READY_OFFSET_LOG_NEEDLES = [
GATEWAY_READY_LOG_NEEDLE,
Buffer.from("listening on ws://"),
Buffer.from("[gateway] http server listening"),
];
const GATEWAY_LOG_TRUNCATED_NEEDLE = "[gateway log truncated after ";
const FORBIDDEN_POST_READY_DEPS_WORK = [/\b(?:npm|pnpm|yarn|corepack) install\b/iu];
const PACKAGE_MANAGER_PROCESS_BASENAME = /^(?:npm|pnpm|yarn|corepack)(?:$|[.-])/u;
const PROCESS_SNAPSHOT_ARGS = ["-ww", "-eo", "pid=,ppid=,args="];
const isolatedStateRoots = new WeakMap();
const activeGatewayChildren = new Set();
const parentSignalHandlers = new Map();
let gatewayExitCleanupInstalled = false;
function readPositiveInt(raw, fallback) {
const text = String(raw ?? "").trim();
@@ -241,7 +259,7 @@ export function activateSmokePlugin(config, pluginId, channels = []) {
const allow = Array.isArray(config.plugins?.allow)
? Array.from(new Set([...config.plugins.allow, pluginId].filter(isNonEmptyString)))
: undefined;
const channelConfig = { ...(config.channels ?? {}) };
const channelConfig = { ...config.channels };
for (const channel of channels) {
channelConfig[channel] = {
...(typeof channelConfig[channel] === "object" && channelConfig[channel] !== null
@@ -268,6 +286,28 @@ export function activateSmokePlugin(config, pluginId, channels = []) {
};
}
function channelActivationEnvName(channel) {
return `${channel
.replace(/[^a-z0-9]+/giu, "_")
.replace(/^_+|_+$/gu, "")
.toUpperCase()}_RUNTIME_SMOKE`;
}
export function withManifestChannelActivationEnv(env, channels = []) {
const nextEnv = { ...env };
for (const channel of channels) {
if (!isNonEmptyString(channel)) {
continue;
}
const key = channelActivationEnvName(channel);
if (key === "_RUNTIME_SMOKE") {
continue;
}
nextEnv[key] ??= "1";
}
return nextEnv;
}
function buildPluginPlan(manifest) {
const contracts =
manifest.contracts && typeof manifest.contracts === "object" ? manifest.contracts : {};
@@ -319,6 +359,44 @@ function formatCapturedOutput(label, buffer) {
return `${prefix}${buffer.text}`;
}
function createBoundedGatewayLog(logPath) {
fs.mkdirSync(path.dirname(logPath), { recursive: true });
const fd = fs.openSync(logPath, "w");
let bytes = 0;
let closed = false;
let truncated = false;
const marker = Buffer.from(
`\n[gateway log truncated after ${String(GATEWAY_LOG_CAPTURE_BYTES)} bytes]\n`,
);
return {
append(chunk) {
if (closed || truncated) {
return;
}
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
const remaining = GATEWAY_LOG_CAPTURE_BYTES - bytes;
if (buffer.length <= remaining) {
fs.writeSync(fd, buffer);
bytes += buffer.length;
return;
}
if (remaining > 0) {
fs.writeSync(fd, buffer.subarray(0, remaining));
}
fs.writeSync(fd, marker);
bytes = GATEWAY_LOG_CAPTURE_BYTES;
truncated = true;
},
close() {
if (closed) {
return;
}
closed = true;
fs.closeSync(fd);
},
};
}
export function runCommand(command, args, options = {}) {
return new Promise((resolve, reject) => {
const { timeoutMs = COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
@@ -384,8 +462,8 @@ export function runCommand(command, args, options = {}) {
});
}
function startGateway(params) {
const log = fs.openSync(params.logPath, "w");
export function startGateway(params) {
const log = createBoundedGatewayLog(params.logPath);
const child = childProcess.spawn(
"node",
[
@@ -405,11 +483,15 @@ function startGateway(params) {
OPENCLAW_SKIP_CHANNELS: params.skipChannels ? "1" : "0",
OPENCLAW_SKIP_PROVIDERS: "0",
},
stdio: ["ignore", log, log],
detached: false,
stdio: ["ignore", "pipe", "pipe"],
detached: process.platform !== "win32",
},
);
fs.closeSync(log);
child.stdout?.on("data", (chunk) => log.append(chunk));
child.stderr?.on("data", (chunk) => log.append(chunk));
child.once("error", () => log.close());
child.once("close", () => log.close());
trackGatewayChild(child);
return child;
}
@@ -417,17 +499,109 @@ export function hasChildExited(child) {
return child.exitCode !== null || (child.signalCode ?? null) !== null;
}
function trackGatewayChild(child) {
activeGatewayChildren.add(child);
const untrack = () => {
if (!processTreeIsAlive(child)) {
activeGatewayChildren.delete(child);
}
};
child.once("error", untrack);
child.once("close", untrack);
installGatewayParentCleanup();
}
function installGatewayParentCleanup() {
if (!gatewayExitCleanupInstalled) {
gatewayExitCleanupInstalled = true;
process.once("exit", () => {
cleanupActiveGatewayChildren("SIGTERM");
});
}
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
if (parentSignalHandlers.has(signal)) {
continue;
}
const handler = () => {
cleanupActiveGatewayChildren(signal);
for (const [registeredSignal, registeredHandler] of parentSignalHandlers) {
process.off(registeredSignal, registeredHandler);
}
parentSignalHandlers.clear();
process.kill(process.pid, signal);
};
parentSignalHandlers.set(signal, handler);
process.once(signal, handler);
}
}
function cleanupActiveGatewayChildren(signal) {
for (const child of activeGatewayChildren) {
signalGateway(child, signal);
if (process.platform !== "win32") {
signalGateway(child, "SIGKILL");
}
}
}
export async function stopGateway(child) {
if (!child || hasChildExited(child)) {
if (!child || !processTreeIsAlive(child)) {
return;
}
child.kill("SIGTERM");
const started = Date.now();
while (!hasChildExited(child) && Date.now() - started < 10000) {
await delay(100);
const waitForExit = async (ms) => {
const deadline = Date.now() + ms;
while (Date.now() < deadline) {
if (!processTreeIsAlive(child)) {
return true;
}
await delay(100);
}
return !processTreeIsAlive(child);
};
signalGateway(child, "SIGTERM");
if (await waitForExit(GATEWAY_TEARDOWN_GRACE_MS)) {
return;
}
if (!hasChildExited(child)) {
child.kill("SIGKILL");
signalGateway(child, "SIGKILL");
await waitForExit(GATEWAY_TEARDOWN_KILL_GRACE_MS);
}
function processTreeIsAlive(child) {
if (!child || typeof child.pid !== "number") {
return !hasChildExited(child);
}
if (process.platform === "win32") {
return !hasChildExited(child);
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
if (error?.code === "EPERM") {
return true;
}
return false;
}
}
function signalGateway(child, signal) {
if (process.platform !== "win32" && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
} catch (error) {
if (error?.code === "ESRCH") {
return;
}
}
}
try {
child.kill(signal);
} catch (error) {
if (error?.code !== "ESRCH") {
throw error;
}
}
}
@@ -659,6 +833,7 @@ async function smokePlugin(pluginId, pluginDir, requiresConfig, pluginIndex, plu
activateSmokePlugin(readConfig(), pluginId, plan.channels),
port,
);
const env = withManifestChannelActivationEnv(process.env, plan.channels);
if (plan.speechProviders[0]) {
const provider = plan.speechProviders[0];
config.messages = {
@@ -682,7 +857,7 @@ async function smokePlugin(pluginId, pluginDir, requiresConfig, pluginIndex, plu
entrypoint,
port,
logPath,
env: process.env,
env,
skipChannels: plan.channels.length === 0,
});
try {
@@ -690,12 +865,12 @@ async function smokePlugin(pluginId, pluginDir, requiresConfig, pluginIndex, plu
await assertBaseGatewayProbes({
entrypoint,
port,
env: process.env,
env,
pluginId,
allowDegradedReadyz: plan.channels.length > 0,
});
await runManifestProbes(plan, { entrypoint, port, env: process.env, pluginId });
await runWatchdog({ child, logPath, port, entrypoint, env: process.env, pluginId });
await runManifestProbes(plan, { entrypoint, port, env, pluginId });
await runWatchdog({ child, logPath, port, entrypoint, env, pluginId });
console.log(`Runtime smoke passed for ${pluginId}`);
} catch (error) {
console.error(tailFile(logPath));
@@ -842,6 +1017,7 @@ async function runWatchdog(options) {
);
}
await retryRpcCall("health", {}, options);
assertGatewayLogNotTruncated(options.logPath);
assertNoPostReadyRuntimeDepsWork(options.logPath, readyOffset);
await assertNoPackageManagerChildren(options.child.pid);
}
@@ -850,6 +1026,16 @@ export function findReadyLogOffset(logPath) {
return findFirstNeedleOffset(logPath, READY_OFFSET_LOG_NEEDLES);
}
export function assertGatewayLogNotTruncated(logPath) {
if (readFileTail(logPath).includes(GATEWAY_LOG_TRUNCATED_NEEDLE)) {
throw new Error(
`gateway log exceeded ${String(
GATEWAY_LOG_CAPTURE_BYTES,
)} bytes; runtime smoke cannot validate complete post-ready output`,
);
}
}
export function assertNoPostReadyRuntimeDepsWork(logPath, readyOffset) {
let stat;
try {
@@ -878,28 +1064,81 @@ export function assertNoPostReadyRuntimeDepsWork(logPath, readyOffset) {
}
}
async function assertNoPackageManagerChildren(pid) {
function commandIncludesPackageManager(args) {
return String(args ?? "")
.trim()
.split(/\s+/u)
.some((token) =>
PACKAGE_MANAGER_PROCESS_BASENAME.test(
path.basename(token.replace(/^['"]|['"]$/gu, "")).toLowerCase(),
),
);
}
function parseProcessSnapshot(stdout) {
const processes = [];
for (const line of String(stdout ?? "").split("\n")) {
const match = /^\s*(\d+)\s+(\d+)\s+(.+?)\s*$/u.exec(line);
if (!match) {
continue;
}
processes.push({
args: match[3],
pid: Number(match[1]),
ppid: Number(match[2]),
});
}
return processes;
}
export function findPackageManagerDescendants(psOutput, rootPid) {
const root = Number(rootPid);
if (!Number.isInteger(root) || root <= 0) {
return [];
}
const childrenByParent = new Map();
for (const processInfo of parseProcessSnapshot(psOutput)) {
const list = childrenByParent.get(processInfo.ppid) ?? [];
list.push(processInfo);
childrenByParent.set(processInfo.ppid, list);
}
const matches = [];
const pending = [...(childrenByParent.get(root) ?? [])];
const seen = new Set();
while (pending.length > 0) {
const current = pending.shift();
if (!current || seen.has(current.pid)) {
continue;
}
seen.add(current.pid);
if (commandIncludesPackageManager(current.args)) {
matches.push(current);
}
pending.push(...(childrenByParent.get(current.pid) ?? []));
}
return matches;
}
export async function assertNoPackageManagerChildren(pid) {
if (!pid || process.platform === "win32") {
return;
}
try {
const { stdout } = await runCommand("pgrep", [
"-P",
String(pid),
"-af",
"npm|pnpm|yarn|corepack",
]);
if (stdout.trim()) {
const { stdout } = await runCommand("ps", PROCESS_SNAPSHOT_ARGS);
const packageManagerDescendants = findPackageManagerDescendants(stdout, pid);
if (packageManagerDescendants.length > 0) {
const formatted = packageManagerDescendants
.map((entry) => `${entry.pid} ${entry.args}`)
.join("\n");
throw new Error(
`package manager child process still running under gateway ${pid}:\n${stdout}`,
`package manager descendant process still running under gateway ${pid}:\n${formatted}`,
);
}
} catch (error) {
if (error?.code === "ENOENT") {
console.log("Runtime deps child-process watchdog skipped: pgrep unavailable");
return;
}
if (error instanceof Error && error.message.includes("failed with 1")) {
console.log("Runtime deps child-process watchdog skipped: ps unavailable");
return;
}
throw error;

View File

@@ -5,6 +5,8 @@ import { spawn } from "@lydell/node-pty";
import { readPositiveIntEnv } from "./env-limits.mjs";
const [logPath, command, ...args] = process.argv.slice(2);
const OUTPUT_MAX_BYTES = readPositiveIntEnv("OPENCLAW_E2E_PTY_OUTPUT_MAX_BYTES", 16 * 1024 * 1024);
const FORCE_KILL_MS = readPositiveIntEnv("OPENCLAW_E2E_PTY_FORCE_KILL_MS", 5_000);
if (!logPath || !command) {
console.error("usage: run-with-pty.mjs <log-path> <command> [args...]");
@@ -21,15 +23,48 @@ const pty = spawn(command, args, {
});
let exiting = false;
let forwardedSignal = null;
let forceKillTimer = null;
const outputLimitMarker = `\n[run-with-pty output truncated after ${OUTPUT_MAX_BYTES} bytes]\n`;
const outputState = {
bytes: 0,
truncated: false,
};
function writeCappedOutput(data) {
if (outputState.truncated) {
return;
}
const buffer = Buffer.from(data);
const remainingBytes = OUTPUT_MAX_BYTES - outputState.bytes;
if (buffer.byteLength <= remainingBytes) {
outputState.bytes += buffer.byteLength;
log.write(buffer);
process.stdout.write(buffer);
return;
}
if (remainingBytes > 0) {
const head = buffer.subarray(0, remainingBytes);
log.write(head);
process.stdout.write(head);
}
outputState.bytes = OUTPUT_MAX_BYTES;
outputState.truncated = true;
log.write(outputLimitMarker);
process.stdout.write(outputLimitMarker);
}
pty.onData((data) => {
log.write(data);
process.stdout.write(data);
writeCappedOutput(data);
});
pty.onExit(({ exitCode, signal }) => {
exiting = true;
clearTimeout(forceKillTimer);
log.end(() => {
if (forwardedSignal) {
process.exit(signalExitCode(forwardedSignal));
}
if (typeof exitCode === "number") {
process.exit(exitCode);
}
@@ -41,10 +76,28 @@ process.stdin.on("data", (chunk) => {
pty.write(chunk.toString("utf8"));
});
for (const signal of ["SIGINT", "SIGTERM"]) {
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
process.on(signal, () => {
if (!exiting) {
forwardedSignal ??= signal;
pty.kill(signal);
forceKillTimer ??= setTimeout(() => {
pty.kill("SIGKILL");
}, FORCE_KILL_MS);
forceKillTimer.unref?.();
}
});
}
function signalExitCode(signal) {
switch (signal) {
case "SIGHUP":
return 129;
case "SIGINT":
return 130;
case "SIGTERM":
return 143;
default:
return 1;
}
}

View File

@@ -7,6 +7,8 @@ import { buildCmdExeCommandLine } from "../../../windows-cmd-helpers.mjs";
const args = process.argv.slice(2);
const command = args.shift();
export const CONFIG_COMMAND_TIMEOUT_MS = 120_000;
export const CONFIG_COMMAND_MAX_BUFFER_BYTES = 4 * 1024 * 1024;
function option(name, fallback) {
const index = args.indexOf(name);
@@ -217,21 +219,34 @@ export function resolveUpgradeSurvivorOpenClawCommand(argv, params = {}) {
};
}
function runOpenClaw(step) {
function errorCode(error) {
return error && typeof error === "object" && "code" in error ? String(error.code) : undefined;
}
export function runUpgradeSurvivorOpenClawStep(step, params = {}) {
const invocation = resolveUpgradeSurvivorOpenClawCommand(step.argv);
const result = spawnSync(invocation.command, invocation.args, {
const run = params.spawnSyncCommand ?? spawnSync;
const timeoutMs = params.timeoutMs ?? CONFIG_COMMAND_TIMEOUT_MS;
const maxBuffer = params.maxBufferBytes ?? CONFIG_COMMAND_MAX_BUFFER_BYTES;
const result = run(invocation.command, invocation.args, {
encoding: "utf8",
env: process.env,
killSignal: "SIGTERM",
maxBuffer,
shell: invocation.shell,
timeout: timeoutMs,
windowsVerbatimArguments: invocation.windowsVerbatimArguments,
});
const code = errorCode(result.error);
return {
id: step.id,
intent: step.intent,
command: invocation.commandLabel,
status: result.status,
signal: result.signal,
ok: result.status === 0,
ok: result.status === 0 && !result.error,
errorCode: code,
errorMessage: result.error?.message ? tail(result.error.message) : undefined,
stdout: tail(result.stdout),
stderr: tail(result.stderr),
};
@@ -268,11 +283,12 @@ function applyRecipe() {
if (!adaptedStep) {
continue;
}
const outcome = runOpenClaw(adaptedStep);
const outcome = runUpgradeSurvivorOpenClawStep(adaptedStep);
summary.steps.push(outcome);
writeJson(summaryPath, summary);
if (!outcome.ok) {
throw new Error(`baseline config recipe failed at ${step.id}`);
const detail = outcome.errorCode ?? outcome.signal ?? outcome.status ?? "unknown";
throw new Error(`baseline config recipe failed at ${step.id}: ${detail}`);
}
}
}

View File

@@ -11,6 +11,7 @@ import {
maybeApprovePendingBridgePairing,
waitFor,
} from "./mcp-channels-harness.ts";
import { createMcpClientTempState } from "./mcp-client-temp-state.ts";
function summarizeSessionRows(rows: Array<Record<string, unknown>> | undefined) {
return (rows ?? []).map((entry) => ({
@@ -92,6 +93,7 @@ async function main() {
const gateway = await connectGateway({ url: gatewayUrl, token: gatewayToken });
let mcpHandle: Awaited<ReturnType<typeof connectMcpClient>> | undefined;
const mcpTempState = createMcpClientTempState({ gatewayToken });
try {
const gatewayConversation = await waitForGatewaySeededConversation(gateway);
@@ -108,14 +110,17 @@ async function main() {
mcpHandle = await connectMcpClient({
gatewayUrl,
gatewayToken,
tempState: mcpTempState,
});
let mcp = mcpHandle.client;
if (await maybeApprovePendingBridgePairing(gateway)) {
await Promise.allSettled([mcp.close(), mcpHandle.transport.close()]);
mcpHandle.cleanup();
mcpHandle = await connectMcpClient({
gatewayUrl,
gatewayToken,
tempState: mcpTempState,
});
mcp = mcpHandle.client;
}
@@ -397,10 +402,13 @@ async function main() {
) + "\n",
);
} finally {
await Promise.allSettled([
...(mcpHandle ? [mcpHandle.client.close(), mcpHandle.transport.close()] : []),
gateway.close(),
]);
const closeTasks: Array<Promise<unknown>> = [gateway.close()];
if (mcpHandle) {
closeTasks.push(mcpHandle.client.close(), mcpHandle.transport.close());
}
await Promise.allSettled(closeTasks);
mcpHandle?.cleanup();
mcpTempState.cleanup();
}
}

View File

@@ -2,7 +2,6 @@
// The mounted test harness imports packaged dist modules so bridge assertions run
// against the OpenClaw npm tarball installed in the functional image.
import { randomUUID } from "node:crypto";
import { mkdirSync, writeFileSync } from "node:fs";
import process from "node:process";
import { setTimeout as delay } from "node:timers/promises";
import { Client } from "@modelcontextprotocol/sdk/client/index.js";
@@ -14,6 +13,7 @@ import { formatErrorMessage } from "../../dist/infra/errors.js";
import { rawDataToString } from "../../dist/infra/ws.js";
import { readStringValue } from "../../dist/normalization-core/string-coerce.js";
import { readMcpChannelLimits } from "./mcp-channel-limits.ts";
import { createMcpClientTempState, type McpClientTempState } from "./mcp-client-temp-state.ts";
import { connectMcpWithTimeout } from "./mcp-connect-timeout.ts";
import { waitForWebSocketOpen } from "./mcp-websocket-open.ts";
@@ -43,6 +43,7 @@ export type GatewayRpcClient = {
export type McpClientHandle = {
client: Client;
cleanup(): void;
transport: StdioClientTransport;
rawMessages: unknown[];
};
@@ -310,11 +311,11 @@ function isRetryableGatewayConnectError(error: Error): boolean {
export async function connectMcpClient(params: {
gatewayUrl: string;
gatewayToken: string;
tempState?: McpClientTempState;
}): Promise<McpClientHandle> {
const tokenDir = "/tmp/openclaw-mcp-client";
const tokenFile = `${tokenDir}/gateway.token`;
mkdirSync(tokenDir, { recursive: true });
writeFileSync(tokenFile, `${params.gatewayToken}\n`, { encoding: "utf8", mode: 0o600 });
const ownsTempState = !params.tempState;
const tempState =
params.tempState ?? createMcpClientTempState({ gatewayToken: params.gatewayToken });
const transport = new StdioClientTransport({
command: "node",
args: [
@@ -324,7 +325,7 @@ export async function connectMcpClient(params: {
"--url",
params.gatewayUrl,
"--token-file",
tokenFile,
tempState.tokenFile,
"--claude-channel-mode",
"on",
],
@@ -332,7 +333,7 @@ export async function connectMcpClient(params: {
env: {
...process.env,
OPENCLAW_ALLOW_INSECURE_PRIVATE_WS: "1",
OPENCLAW_STATE_DIR: "/tmp/openclaw-mcp-client",
OPENCLAW_STATE_DIR: tempState.stateDir,
},
stderr: "pipe",
});
@@ -345,8 +346,21 @@ export async function connectMcpClient(params: {
});
const client = new Client({ name: "docker-mcp-channels", version: "1.0.0" });
await connectMcpWithTimeout(client, transport, MCP_CONNECT_TIMEOUT_MS);
return { client, transport, rawMessages };
try {
await connectMcpWithTimeout(client, transport, MCP_CONNECT_TIMEOUT_MS);
return {
client,
cleanup: ownsTempState ? tempState.cleanup : () => {},
transport,
rawMessages,
};
} catch (error) {
await Promise.allSettled([client.close(), transport.close()]);
if (ownsTempState) {
tempState.cleanup();
}
throw error;
}
}
export async function maybeApprovePendingBridgePairing(

View File

@@ -0,0 +1,29 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
export type McpClientTempState = {
cleanup: () => void;
root: string;
stateDir: string;
tokenFile: string;
};
export function createMcpClientTempState(params: {
gatewayToken: string;
tempRoot?: string;
}): McpClientTempState {
const root = mkdtempSync(path.join(params.tempRoot ?? tmpdir(), "openclaw-mcp-client-"));
const stateDir = path.join(root, "state");
const tokenFile = path.join(root, "gateway.token");
mkdirSync(stateDir, { recursive: true });
writeFileSync(tokenFile, `${params.gatewayToken}\n`, { encoding: "utf8", mode: 0o600 });
return {
cleanup: () => {
rmSync(root, { force: true, recursive: true });
},
root,
stateDir,
tokenFile,
};
}

View File

@@ -12,8 +12,12 @@ export interface GuestExecOptions {
export interface WindowsBackgroundPowerShellOptions {
append?: (chunk: string | Uint8Array) => void;
beforeLaunchAttempt?: () => void;
completedLogDrainGraceMs?: number;
label: string;
logChunkBytes?: number;
onLaunchRetry?: (message: string) => void;
pollIntervalMs?: number;
runCommand?: typeof run;
script: string;
timeoutMs: number;
vmName: string;
@@ -52,6 +56,13 @@ export async function runWindowsBackgroundPowerShell(
options: WindowsBackgroundPowerShellOptions,
): Promise<void> {
const append = options.append;
const completedLogDrainGraceMs = Math.max(
1,
Math.floor(options.completedLogDrainGraceMs ?? 30_000),
);
const logChunkBytes = Math.max(1, Math.floor(options.logChunkBytes ?? 1024 * 1024));
const pollIntervalMs = Math.max(1, Math.floor(options.pollIntervalMs ?? 5_000));
const runCommand = options.runCommand ?? run;
const safeLabel = options.label.replaceAll(/[^A-Za-z0-9_-]/g, "-");
const nonce = `${safeLabel}-${Date.now()}-${Math.floor(Math.random() * 100000)}`;
const fileBase = `openclaw-parallels-${nonce}`;
@@ -59,7 +70,8 @@ export async function runWindowsBackgroundPowerShell(
$scriptPath = "$base.ps1"
$logPath = "$base.log"
$donePath = "$base.done"
$exitPath = "$base.exit"`;
$exitPath = "$base.exit"
$pidPath = "$base.pid"`;
const payload = `$ErrorActionPreference = 'Stop'
$PSNativeCommandUseErrorActionPreference = $false
${pathsScript}
@@ -74,7 +86,7 @@ ${options.script}
} finally {
Set-Content -Path $donePath -Value 'done' -Encoding UTF8
}`;
const writeScript = run(
const writeScript = runCommand(
"prlctl",
[
"exec",
@@ -86,7 +98,7 @@ ${options.script}
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue
[System.IO.File]::WriteAllText($scriptPath, [Console]::In.ReadToEnd(), [System.Text.UTF8Encoding]::new($false))
if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not written" }`),
],
@@ -99,81 +111,102 @@ if (!(Test-Path $scriptPath)) { throw "${safeLabel} background script was not wr
);
}
const deadline = Date.now() + options.timeoutMs;
let launched = false;
let lastLaunchStatus = 0;
for (let attempt = 1; attempt <= 5 && Date.now() < deadline; attempt++) {
options.beforeLaunchAttempt?.();
const launch = run(
"prlctl",
[
"exec",
options.vmName,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
Start-Process -FilePath powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath)
let doneSeen = false;
try {
const deadline = Date.now() + options.timeoutMs;
let launched = false;
let lastLaunchStatus = 0;
for (let attempt = 1; attempt <= 5 && Date.now() < deadline; attempt++) {
options.beforeLaunchAttempt?.();
const launch = runCommand(
"prlctl",
[
"exec",
options.vmName,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
$process = Start-Process -FilePath powershell.exe -WindowStyle Hidden -ArgumentList @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $scriptPath) -PassThru
Set-Content -Path $pidPath -Value $process.Id -Encoding UTF8
'started'`),
],
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
);
appendOutput(append, launch);
if (launch.status === 0 && launch.stdout.includes("started")) {
launched = true;
break;
}
lastLaunchStatus = launch.status;
if (launch.status === 0 || launch.status === 124) {
const materialized = waitForWindowsBackgroundMaterialized({
append,
deadline,
pathsScript,
vmName: options.vmName,
});
if (materialized) {
],
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
);
appendOutput(append, launch);
if (launch.status === 0 && launch.stdout.includes("started")) {
launched = true;
break;
}
options.onLaunchRetry?.(
`${options.label} launch retry ${attempt}: background log/done file did not materialize`,
lastLaunchStatus = launch.status;
if (launch.status === 0 || launch.status === 124) {
const materialized = waitForWindowsBackgroundMaterialized({
append,
deadline,
pathsScript,
runCommand,
vmName: options.vmName,
});
if (materialized) {
launched = true;
break;
}
options.onLaunchRetry?.(
`${options.label} launch retry ${attempt}: background log/done file did not materialize`,
);
continue;
}
if (launch.stdout.includes("restoring") || launch.stderr.includes("restoring")) {
options.onLaunchRetry?.(`${options.label} launch retry ${attempt}: VM is still restoring`);
await sleep(5_000);
continue;
}
throw new Error(`${options.label} background launch failed with exit code ${launch.status}`);
}
if (!launched) {
throw new Error(
`${options.label} background launch failed with exit code ${lastLaunchStatus}`,
);
continue;
}
if (launch.stdout.includes("restoring") || launch.stderr.includes("restoring")) {
options.onLaunchRetry?.(`${options.label} launch retry ${attempt}: VM is still restoring`);
await sleep(5_000);
continue;
}
throw new Error(`${options.label} background launch failed with exit code ${launch.status}`);
}
if (!launched) {
throw new Error(`${options.label} background launch failed with exit code ${lastLaunchStatus}`);
}
let lastLogOffset = 0;
while (Date.now() < deadline) {
const poll = run(
"prlctl",
[
"exec",
options.vmName,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
let lastLogOffset = 0;
let completedLogDrainDeadline = 0;
const activeDeadline = () => (doneSeen ? completedLogDrainDeadline : deadline);
while (Date.now() < activeDeadline()) {
const poll = runCommand(
"prlctl",
[
"exec",
options.vmName,
"--current-user",
"powershell.exe",
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
$offset = ${lastLogOffset}
if (Test-Path $logPath) {
$bytes = [System.IO.File]::ReadAllBytes($logPath)
if ($bytes.Length -gt $offset) {
"__OPENCLAW_LOG_OFFSET__:$($bytes.Length)"
[System.Text.Encoding]::UTF8.GetString($bytes, $offset, $bytes.Length - $offset)
$stream = [System.IO.File]::Open($logPath, [System.IO.FileMode]::Open, [System.IO.FileAccess]::Read, [System.IO.FileShare]::ReadWrite)
try {
$length = $stream.Length
"__OPENCLAW_LOG_LENGTH__:$length"
if ($length -gt $offset) {
[void]$stream.Seek($offset, [System.IO.SeekOrigin]::Begin)
$count = [int][Math]::Min($length - $offset, ${logChunkBytes})
$buffer = New-Object byte[] $count
$read = $stream.Read($buffer, 0, $count)
if ($read -gt 0) {
$nextOffset = $offset + $read
"__OPENCLAW_LOG_OFFSET__:$nextOffset"
[System.Text.Encoding]::UTF8.GetString($buffer, 0, $read)
}
}
} finally {
$stream.Dispose()
}
}
if (Test-Path $donePath) {
@@ -183,37 +216,53 @@ if (Test-Path $donePath) {
if ($backgroundExit -ne '0') { exit 23 }
exit 0
}`),
],
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
);
appendOutput(append, poll);
const offsetMatch = poll.stdout.match(/__OPENCLAW_LOG_OFFSET__:(\d+)/);
if (offsetMatch) {
lastLogOffset = Number(offsetMatch[1]);
}
if (poll.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) {
const exitMatch = poll.stdout.match(/__OPENCLAW_BACKGROUND_EXIT__:(\S+)/);
const backgroundExit = exitMatch?.[1] ?? "0";
if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) {
throw new Error(`${options.label} failed`);
],
{ check: false, quiet: true, timeoutMs: timeoutBefore(deadline, 30_000) },
);
appendOutput(append, poll);
const offsetMatch = poll.stdout.match(/__OPENCLAW_LOG_OFFSET__:(\d+)/);
if (offsetMatch) {
lastLogOffset = Number(offsetMatch[1]);
}
cleanupWindowsBackground(options.vmName, pathsScript);
return;
const lengthMatch = poll.stdout.match(/__OPENCLAW_LOG_LENGTH__:(\d+)/);
const logLength = lengthMatch ? Number(lengthMatch[1]) : lastLogOffset;
if (poll.stdout.includes("__OPENCLAW_BACKGROUND_DONE__")) {
doneSeen = true;
completedLogDrainDeadline ||= Date.now() + completedLogDrainGraceMs;
if (lastLogOffset < logLength) {
await sleep(Math.min(pollIntervalMs, 100));
continue;
}
const exitMatch = poll.stdout.match(/__OPENCLAW_BACKGROUND_EXIT__:(\S+)/);
const backgroundExit = exitMatch?.[1] ?? "0";
if (backgroundExit !== "0" || (poll.status !== 0 && poll.status !== 124)) {
throw new Error(`${options.label} failed`);
}
return;
}
await sleep(pollIntervalMs);
}
await sleep(5_000);
if (doneSeen) {
throw new Error(`${options.label} completed but log drain timed out`);
}
throw new Error(`${options.label} timed out`);
} finally {
cleanupWindowsBackground(options.vmName, pathsScript, runCommand, {
stopProcessTree: !doneSeen,
});
}
throw new Error(`${options.label} timed out`);
}
function waitForWindowsBackgroundMaterialized(params: {
append?: (chunk: string | Uint8Array) => void;
deadline: number;
pathsScript: string;
runCommand: typeof run;
vmName: string;
}): boolean {
const materializeDeadline = Math.min(Date.now() + 45_000, params.deadline);
while (Date.now() < materializeDeadline) {
const result = run(
const result = params.runCommand(
"prlctl",
[
"exec",
@@ -239,8 +288,28 @@ if ((Test-Path $logPath) -or (Test-Path $donePath)) {
return false;
}
function cleanupWindowsBackground(vmName: string, pathsScript: string): void {
run(
function cleanupWindowsBackground(
vmName: string,
pathsScript: string,
runCommand: typeof run,
options: { stopProcessTree: boolean },
): void {
const stopProcessTree = options.stopProcessTree
? `function Stop-OpenClawBackgroundProcessTree([int]$ProcessId) {
Get-CimInstance Win32_Process -Filter "ParentProcessId=$ProcessId" -ErrorAction SilentlyContinue | ForEach-Object {
Stop-OpenClawBackgroundProcessTree ([int]$_.ProcessId)
}
Stop-Process -Id $ProcessId -Force -ErrorAction SilentlyContinue
}
if (Test-Path $pidPath) {
$backgroundPid = (Get-Content -Path $pidPath -Raw).Trim()
if ($backgroundPid) {
Stop-OpenClawBackgroundProcessTree ([int]$backgroundPid)
}
}
`
: "";
runCommand(
"prlctl",
[
"exec",
@@ -252,7 +321,8 @@ function cleanupWindowsBackground(vmName: string, pathsScript: string): void {
"Bypass",
"-EncodedCommand",
encodePowerShell(`${pathsScript}
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath -Force -ErrorAction SilentlyContinue`),
${stopProcessTree}
Remove-Item -Path $scriptPath, $logPath, $donePath, $exitPath, $pidPath -Force -ErrorAction SilentlyContinue`),
],
{ check: false, quiet: true, timeoutMs: 30_000 },
);

View File

@@ -81,20 +81,54 @@ export async function startHostServer(input: {
hostIp: input.hostIp,
port: actualPort,
stop: async () => {
child.kill("SIGTERM");
await new Promise<void>((resolve) => {
child.once("exit", () => resolve());
setTimeout(() => {
child.kill("SIGKILL");
resolve();
}, 2_000).unref();
});
await stopHostServerChild(child);
},
urlFor: (filePath) =>
`http://${input.hostIp}:${actualPort}/${encodeURIComponent(path.basename(filePath))}`,
};
}
async function stopHostServerChild(
child: ChildProcessWithoutNullStreams,
terminateTimeoutMs = 2_000,
killTimeoutMs = 1_500,
): Promise<boolean> {
if (child.exitCode != null) {
return true;
}
child.kill("SIGTERM");
if (await waitForChildExit(child, terminateTimeoutMs)) {
return true;
}
child.kill("SIGKILL");
return await waitForChildExit(child, killTimeoutMs);
}
async function waitForChildExit(
child: ChildProcessWithoutNullStreams,
timeoutMs: number,
): Promise<boolean> {
if (child.exitCode != null) {
return true;
}
return await new Promise<boolean>((resolve) => {
let settled = false;
const onExit = () => settle(true);
const timeout = setTimeout(() => settle(child.exitCode != null), timeoutMs);
timeout.unref();
function settle(exited: boolean): void {
if (settled) {
return;
}
settled = true;
clearTimeout(timeout);
child.off("exit", onExit);
resolve(exited);
}
child.once("exit", onExit);
});
}
async function waitForHostServer(
child: ChildProcessWithoutNullStreams,
port: number,
@@ -160,4 +194,5 @@ async function delay(ms: number): Promise<void> {
export const testing = {
appendBoundedOutput,
stopHostServerChild,
};

View File

@@ -363,7 +363,8 @@ class LinuxSmoke extends SmokeRunController<LinuxOptions> {
private phase = async (name: string, timeoutSeconds: number, fn: () => Promise<void> | void) =>
await this.phases.phase(name, timeoutSeconds, fn);
private remainingPhaseTimeoutMs = (): number | undefined => this.phases.remainingTimeoutMs();
private remainingPhaseTimeoutMs = (fallbackMs?: number): number | undefined =>
this.phases.remainingTimeoutMs(fallbackMs);
private logGuestPreflight(): void {
this.guestBash(String.raw`set -euo pipefail
@@ -406,11 +407,17 @@ printf 'preflight.npmRoot=%s\n' "$(npm root -g 2>/dev/null || true)"`);
say(`Restore snapshot ${this.options.snapshotHint} (${this.snapshot.id})`);
run("prlctl", ["snapshot-switch", this.options.vmName, "--id", this.snapshot.id], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
});
if (this.snapshot.state === "poweroff") {
waitForVmStatus(this.options.vmName, "stopped", 180);
waitForVmStatus(this.options.vmName, "stopped", 180, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
});
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], { quiet: true });
run("prlctl", ["start", this.options.vmName], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
});
}
this.waitForGuestReady();
}

View File

@@ -565,8 +565,8 @@ class MacosSmoke {
await this.phases.phase(name, timeoutSeconds, fn);
}
private remainingPhaseTimeoutMs(): number | undefined {
return this.phases.remainingTimeoutMs();
private remainingPhaseTimeoutMs(fallbackMs?: number): number | undefined {
return this.phases.remainingTimeoutMs(fallbackMs);
}
private async phaseReturns(
@@ -653,6 +653,7 @@ exec node "$entry" ${argv}`,
run("prlctl", ["exec", this.options.vmName, "/usr/bin/stat", "-f", "%Su", "/dev/console"], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
})
.stdout.trim()
.replaceAll("\r", "")
@@ -671,6 +672,7 @@ exec node "$entry" ${argv}`,
{
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
},
).stdout.replaceAll("\r", "");
for (const line of users.split("\n")) {
@@ -700,7 +702,7 @@ exec node "$entry" ${argv}`,
`/Users/${user}`,
"NFSHomeDirectory",
],
{ check: false, quiet: true },
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(30_000) },
).stdout.replaceAll("\r", "");
const match = /^NFSHomeDirectory:\s+(.+)$/m.exec(output);
return match?.[1]?.trim() || `/Users/${user}`;
@@ -713,7 +715,7 @@ exec node "$entry" ${argv}`,
const result = run(
"prlctl",
["snapshot-switch", this.options.vmName, "--id", this.snapshot.id, "--skip-resume"],
{ check: false, quiet: true, timeoutMs: 360_000 },
{ check: false, quiet: true, timeoutMs: this.remainingPhaseTimeoutMs(360_000) },
);
this.log(result.stdout);
this.log(result.stderr);
@@ -725,10 +727,17 @@ exec node "$entry" ${argv}`,
const status = run("prlctl", ["status", this.options.vmName], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(60_000),
}).stdout;
if (status.includes(" running") || status.includes(" suspended")) {
run("prlctl", ["stop", this.options.vmName, "--kill"], { check: false, quiet: true });
waitForVmStatus(this.options.vmName, "stopped", 360);
run("prlctl", ["stop", this.options.vmName, "--kill"], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
});
waitForVmStatus(this.options.vmName, "stopped", 360, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
});
}
run("sleep", ["3"], { quiet: true });
}
@@ -738,15 +747,23 @@ exec node "$entry" ${argv}`,
const status = run("prlctl", ["status", this.options.vmName], {
check: false,
quiet: true,
timeoutMs: 60_000,
timeoutMs: this.remainingPhaseTimeoutMs(60_000),
}).stdout;
if (this.snapshot.state === "poweroff" || status.includes(" stopped")) {
waitForVmStatus(this.options.vmName, "stopped", 360);
waitForVmStatus(this.options.vmName, "stopped", 360, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
});
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], { quiet: true });
run("prlctl", ["start", this.options.vmName], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
});
} else if (status.includes(" suspended")) {
say(`Resume restored snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], { quiet: true });
run("prlctl", ["start", this.options.vmName], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
});
}
this.waitForCurrentUser();
}

View File

@@ -67,6 +67,12 @@ interface UpdateJobContext {
signal: AbortSignal;
}
interface SpawnLoggedOptions {
timeoutKillGraceMs?: number;
timeoutLabel?: string;
timeoutMs?: number;
}
interface NpmUpdateSummary {
packageSpec: string;
updateTarget: string;
@@ -104,6 +110,173 @@ const windowsVm = "Windows 11";
const linuxVmDefault = "Ubuntu 26.04";
const updateTimeoutSeconds = readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_TIMEOUT_S", 1200);
const updateCleanupBackstopMs = 60_000;
const freshLaneTimeoutKillGraceMs = readPositiveIntEnv(
"OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_KILL_GRACE_MS",
2_000,
);
const activeLoggedChildren = new Set<ReturnType<typeof spawn>>();
const loggedParentSignalHandlers = new Map<NodeJS.Signals, () => void>();
let loggedExitCleanupInstalled = false;
export function freshLaneTimeoutMs(platform: Platform): number {
const defaultSeconds = platform === "windows" ? 90 * 60 : 75 * 60;
return readPositiveIntEnv("OPENCLAW_PARALLELS_NPM_UPDATE_FRESH_TIMEOUT_S", defaultSeconds) * 1000;
}
export function spawnLoggedCommand(
command: string,
args: string[],
logPath: string,
env: NodeJS.ProcessEnv = {},
onOutput: (text: string) => void = () => undefined,
options: SpawnLoggedOptions = {},
): Promise<number> {
return new Promise((resolve, reject) => {
writeFileSync(logPath, "", "utf8");
const child = spawn(command, args, {
cwd: repoRoot,
detached: process.platform !== "win32",
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
trackLoggedChild(child);
let timedOut = false;
let settled = false;
let forceKillTimer: NodeJS.Timeout | undefined;
const append = (text: string) => {
appendFileSync(logPath, text, "utf8");
onOutput(text);
};
const timeoutMs = options.timeoutMs ?? 0;
const timeoutTimer =
timeoutMs > 0
? setTimeout(() => {
timedOut = true;
append(
`\n[${options.timeoutLabel ?? `${command} ${args.join(" ")}`} timed out after ${timeoutMs}ms]\n`,
);
signalLoggedChild(child, "SIGTERM");
forceKillTimer = setTimeout(
() => signalLoggedChild(child, "SIGKILL"),
options.timeoutKillGraceMs ?? freshLaneTimeoutKillGraceMs,
);
}, timeoutMs)
: undefined;
child.stdout.on("data", (chunk: Buffer) => {
append(chunk.toString("utf8"));
});
child.stderr.on("data", (chunk: Buffer) => {
append(chunk.toString("utf8"));
});
child.on("error", (error) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutTimer);
clearTimeout(forceKillTimer);
untrackLoggedChild(child);
reject(error);
});
child.on("close", (code) => {
if (settled) {
return;
}
settled = true;
clearTimeout(timeoutTimer);
clearTimeout(forceKillTimer);
if (timedOut && loggedProcessTreeIsAlive(child)) {
signalLoggedChild(child, "SIGKILL");
}
untrackLoggedChild(child);
resolve(timedOut ? 124 : (code ?? 1));
});
});
}
function trackLoggedChild(child: ReturnType<typeof spawn>) {
activeLoggedChildren.add(child);
child.once("close", () => {
if (!loggedProcessTreeIsAlive(child)) {
activeLoggedChildren.delete(child);
}
});
child.once("error", () => {
if (!loggedProcessTreeIsAlive(child)) {
activeLoggedChildren.delete(child);
}
});
installLoggedParentCleanup();
}
function untrackLoggedChild(child: ReturnType<typeof spawn>) {
if (!loggedProcessTreeIsAlive(child)) {
activeLoggedChildren.delete(child);
}
}
function installLoggedParentCleanup() {
if (!loggedExitCleanupInstalled) {
loggedExitCleanupInstalled = true;
process.once("exit", () => cleanupActiveLoggedChildren("SIGTERM"));
}
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"] as const) {
if (loggedParentSignalHandlers.has(signal)) {
continue;
}
const handler = () => {
cleanupActiveLoggedChildren(signal);
for (const [registeredSignal, registeredHandler] of loggedParentSignalHandlers) {
process.off(registeredSignal, registeredHandler);
}
loggedParentSignalHandlers.clear();
process.kill(process.pid, signal);
};
loggedParentSignalHandlers.set(signal, handler);
process.once(signal, handler);
}
}
function cleanupActiveLoggedChildren(signal: NodeJS.Signals) {
for (const child of activeLoggedChildren) {
signalLoggedChild(child, signal);
if (process.platform !== "win32") {
signalLoggedChild(child, "SIGKILL");
}
}
}
function loggedProcessTreeIsAlive(child: ReturnType<typeof spawn>): boolean {
if (process.platform === "win32" || typeof child.pid !== "number") {
return child.exitCode === null && child.signalCode === null;
}
try {
process.kill(-child.pid, 0);
return true;
} catch (error) {
return error instanceof Error && "code" in error && error.code === "EPERM";
}
}
function signalLoggedChild(child: ReturnType<typeof spawn>, signal: NodeJS.Signals) {
if (process.platform !== "win32" && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
} catch (error) {
if (error instanceof Error && "code" in error && error.code === "ESRCH") {
return;
}
}
}
try {
child.kill(signal);
} catch (error) {
if (!(error instanceof Error && "code" in error && error.code === "ESRCH")) {
throw error;
}
}
}
function usage(): string {
return `Usage: bash scripts/e2e/parallels-npm-update-smoke.sh [options]
@@ -238,7 +411,7 @@ function parseOpenClawPackageSpecVersion(spec: string): string {
return resolveOpenClawRegistryVersion(value) || "";
}
class NpmUpdateSmoke {
export class NpmUpdateSmoke {
private auth: ProviderAuth;
private windowsAuth: ProviderAuth;
private runDir = "";
@@ -250,7 +423,7 @@ class NpmUpdateSmoke {
private harnessCheckoutVersion = "";
private harnessTargetFamily = "";
private hostIp = "";
private server: HostServer | null = null;
protected server: HostServer | null = null;
private artifact: PackageArtifact | null = null;
private freshTargetSpec = "";
private startedAt = Date.now();
@@ -282,52 +455,60 @@ class NpmUpdateSmoke {
async run(): Promise<void> {
this.startedAt = Date.now();
this.runDir = await makeTempDir("openclaw-parallels-npm-update.");
this.tgzDir = await makeTempDir("openclaw-parallels-npm-update-tgz.");
this.runDir = await this.makeRunTempDir("openclaw-parallels-npm-update.");
this.tgzDir = await this.makeRunTempDir("openclaw-parallels-npm-update-tgz.");
try {
this.latestVersion = resolveLatestVersion();
this.packageSpec = this.options.packageSpec || `openclaw@${this.latestVersion}`;
this.currentHead = run("git", ["rev-parse", "HEAD"], { quiet: true }).stdout.trim();
this.currentHeadShort = run("git", ["rev-parse", "--short=7", "HEAD"], {
quiet: true,
}).stdout.trim();
this.harnessCheckoutVersion = readHarnessCheckoutVersion();
this.hostIp = resolveHostIp(this.options.hostIp ?? "");
this.configurePublishedTargets();
this.assertPublishedTargetMatchesHarnessCheckout();
if (this.options.platforms.has("linux")) {
this.linuxVm = resolveUbuntuVmName(linuxVmDefault);
}
this.preflightRegistryUpdateTarget();
say(`Run fresh npm baseline: ${this.packageSpec}`);
say(`Platforms: ${[...this.options.platforms].join(",")}`);
say(`Run dir: ${this.runDir}`);
await this.runFreshBaselines();
await this.prepareUpdateTarget();
say(`Run same-guest openclaw update to ${this.updateTargetEffective}`);
await this.runSameGuestUpdates();
if (this.freshTargetSpec) {
say(`Run fresh target npm install: ${this.freshTargetSpec}`);
await this.runFreshTargetInstalls();
}
const summaryPath = await this.writeSummary();
if (this.options.json) {
process.stdout.write(await readFile(summaryPath, "utf8"));
} else {
say(`Run dir: ${this.runDir}`);
process.stdout.write(await readFile(summaryPath, "utf8"));
}
await this.runSteps();
} finally {
await this.server?.stop().catch(() => undefined);
await rm(this.tgzDir, { force: true, recursive: true }).catch(() => undefined);
}
}
protected async makeRunTempDir(prefix: string): Promise<string> {
return await makeTempDir(prefix);
}
protected async runSteps(): Promise<void> {
this.latestVersion = resolveLatestVersion();
this.packageSpec = this.options.packageSpec || `openclaw@${this.latestVersion}`;
this.currentHead = run("git", ["rev-parse", "HEAD"], { quiet: true }).stdout.trim();
this.currentHeadShort = run("git", ["rev-parse", "--short=7", "HEAD"], {
quiet: true,
}).stdout.trim();
this.harnessCheckoutVersion = readHarnessCheckoutVersion();
this.hostIp = resolveHostIp(this.options.hostIp ?? "");
this.configurePublishedTargets();
this.assertPublishedTargetMatchesHarnessCheckout();
if (this.options.platforms.has("linux")) {
this.linuxVm = resolveUbuntuVmName(linuxVmDefault);
}
this.preflightRegistryUpdateTarget();
say(`Run fresh npm baseline: ${this.packageSpec}`);
say(`Platforms: ${[...this.options.platforms].join(",")}`);
say(`Run dir: ${this.runDir}`);
await this.runFreshBaselines();
await this.prepareUpdateTarget();
say(`Run same-guest openclaw update to ${this.updateTargetEffective}`);
await this.runSameGuestUpdates();
if (this.freshTargetSpec) {
say(`Run fresh target npm install: ${this.freshTargetSpec}`);
await this.runFreshTargetInstalls();
}
const summaryPath = await this.writeSummary();
if (this.options.json) {
process.stdout.write(await readFile(summaryPath, "utf8"));
} else {
say(`Run dir: ${this.runDir}`);
process.stdout.write(await readFile(summaryPath, "utf8"));
}
}
private async runFreshBaselines(): Promise<void> {
const jobs: Job[] = [];
if (this.options.platforms.has("macos")) {
@@ -432,8 +613,16 @@ class NpmUpdateSmoke {
rerunCommand: this.formatRerun("bash", args, env),
startedAt,
};
job.promise = this.spawnLogged("bash", args, logPath, env, (text) =>
this.noteJobOutput(job, text),
job.promise = this.spawnLogged(
"bash",
args,
logPath,
env,
(text) => this.noteJobOutput(job, text),
{
timeoutLabel: `${label} ${phase}`,
timeoutMs: freshLaneTimeoutMs(platform),
},
).finally(() => {
job.durationMs = Date.now() - job.startedAt;
job.done = true;
@@ -636,29 +825,9 @@ class NpmUpdateSmoke {
logPath: string,
env: NodeJS.ProcessEnv = {},
onOutput: (text: string) => void = () => undefined,
options: SpawnLoggedOptions = {},
): Promise<number> {
return new Promise((resolve, reject) => {
writeFileSync(logPath, "", "utf8");
const child = spawn(command, args, {
cwd: repoRoot,
env: { ...process.env, ...env },
stdio: ["ignore", "pipe", "pipe"],
});
child.stdout.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8");
appendFileSync(logPath, text, "utf8");
onOutput(text);
});
child.stderr.on("data", (chunk: Buffer) => {
const text = chunk.toString("utf8");
appendFileSync(logPath, text, "utf8");
onOutput(text);
});
child.on("error", reject);
child.on("close", (code) => {
resolve(code ?? 1);
});
});
return spawnLoggedCommand(command, args, logPath, env, onOutput, options);
}
private async monitorJobs(label: string, jobs: Job[]): Promise<void> {

View File

@@ -1,10 +1,17 @@
import { die, run, say, warn } from "./host-command.ts";
const PRLCTL_STATUS_TIMEOUT_MS = 30_000;
const PRLCTL_TRANSITION_TIMEOUT_MS = 120_000;
interface PrlctlVmListItem {
name?: string;
status?: string;
}
export interface WaitForVmStatusOptions {
probeTimeoutMs?: () => number | undefined;
}
export function listVmNames(): string[] {
return listVms()
.map((item) => (item.name ?? "").trim())
@@ -15,12 +22,18 @@ export function vmStatus(vmName: string): string {
return listVms().find((vm) => vm.name === vmName)?.status || "missing";
}
export function waitForVmStatus(vmName: string, expected: string, timeoutSeconds: number): void {
export function waitForVmStatus(
vmName: string,
expected: string,
timeoutSeconds: number,
options: WaitForVmStatusOptions = {},
): void {
const deadline = Date.now() + timeoutSeconds * 1000;
while (Date.now() < deadline) {
const status = run("prlctl", ["status", vmName], {
check: false,
quiet: true,
timeoutMs: options.probeTimeoutMs?.() ?? PRLCTL_STATUS_TIMEOUT_MS,
}).stdout;
if (status.includes(` ${expected}`)) {
return;
@@ -39,10 +52,16 @@ export function ensureVmRunning(vmName: string, timeoutSeconds = 180): void {
}
if (status === "stopped") {
say(`Start ${vmName} before update phase`);
run("prlctl", ["start", vmName], { quiet: true });
run("prlctl", ["start", vmName], {
quiet: true,
timeoutMs: PRLCTL_TRANSITION_TIMEOUT_MS,
});
} else if (status === "suspended" || status === "paused") {
say(`Resume ${vmName} before update phase`);
run("prlctl", ["resume", vmName], { quiet: true });
run("prlctl", ["resume", vmName], {
quiet: true,
timeoutMs: PRLCTL_TRANSITION_TIMEOUT_MS,
});
} else if (status === "missing") {
die(`VM not found before update phase: ${vmName}`);
}
@@ -79,7 +98,10 @@ export function resolveUbuntuVmName(requested: string, explicit = false): string
function listVms(): PrlctlVmListItem[] {
return JSON.parse(
run("prlctl", ["list", "--all", "--json"], { quiet: true }).stdout,
run("prlctl", ["list", "--all", "--json"], {
quiet: true,
timeoutMs: PRLCTL_STATUS_TIMEOUT_MS,
}).stdout,
) as PrlctlVmListItem[];
}

View File

@@ -1,9 +1,25 @@
import { appendFileSync } from "node:fs";
import { writeFile } from "node:fs/promises";
import path from "node:path";
import { say, warn } from "./host-command.ts";
export const PHASE_LOG_TAIL_MAX_BYTES = 512 * 1024;
function appendTextTail(current: string, chunk: string, maxBytes: number): string {
const text = chunk.endsWith("\n") ? chunk : `${chunk}\n`;
const combined = `${current}${text}`;
if (Buffer.byteLength(combined) <= maxBytes) {
return combined;
}
const marker = `[phase log tail truncated to last ${maxBytes} bytes]\n`;
const tailBytes = Math.max(0, maxBytes - Buffer.byteLength(marker));
const tail = Buffer.from(combined).subarray(-tailBytes).toString("utf8");
return `${marker}${tail}`;
}
export class PhaseRunner {
private logText = "";
private logTail = "";
private currentLogPath: string | undefined;
private deadlineMs = 0;
private timings: Array<{
durationMs: number;
@@ -13,13 +29,18 @@ export class PhaseRunner {
timeoutSeconds: number;
}> = [];
constructor(private runDir: string) {}
constructor(
private runDir: string,
private logTailMaxBytes = PHASE_LOG_TAIL_MAX_BYTES,
) {}
async phase(name: string, timeoutSeconds: number, fn: () => Promise<void> | void): Promise<void> {
const logPath = path.join(this.runDir, `${name}.log`);
say(name);
this.logText = "";
this.logTail = "";
this.currentLogPath = logPath;
this.deadlineMs = Date.now() + timeoutSeconds * 1000;
await writeFile(logPath, "", "utf8");
const startedAt = Date.now();
let status: "pass" | "fail" = "fail";
let timer: NodeJS.Timeout | undefined;
@@ -31,13 +52,11 @@ export class PhaseRunner {
});
try {
await Promise.race([Promise.resolve(fn()), timeout]);
await writeFile(logPath, this.logText, "utf8");
status = "pass";
} catch (error) {
await writeFile(logPath, this.logText, "utf8").catch(() => undefined);
warn(`${name} failed`);
warn(`log tail: ${logPath}`);
process.stderr.write(this.logText.split("\n").slice(-80).join("\n"));
process.stderr.write(this.logTail.split("\n").slice(-80).join("\n"));
process.stderr.write("\n");
throw error;
} finally {
@@ -52,6 +71,7 @@ export class PhaseRunner {
if (timer) {
clearTimeout(timer);
}
this.currentLogPath = undefined;
this.deadlineMs = 0;
}
}
@@ -84,10 +104,11 @@ export class PhaseRunner {
if (!text) {
return;
}
this.logText += text;
if (!text.endsWith("\n")) {
this.logText += "\n";
const line = text.endsWith("\n") ? text : `${text}\n`;
if (this.currentLogPath) {
appendFileSync(this.currentLogPath, line, "utf8");
}
this.logTail = appendTextTail(this.logTail, line, this.logTailMaxBytes);
}
private async writeTimings(): Promise<void> {

View File

@@ -1,10 +1,18 @@
import { mkdtempSync } from "node:fs";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import path from "node:path";
import { parsePositiveInt, readPositiveIntEnv } from "./env-limits.ts";
import { die, run } from "./host-command.ts";
import type { Mode, Platform, Provider, ProviderAuth } from "./types.ts";
type ResolveLatestVersionDeps = {
createTempDir?: typeof mkdtempSync;
removeDir?: typeof rmSync;
runCommand?: typeof run;
tempDir?: typeof tmpdir;
writeFile?: typeof writeFileSync;
};
export function parseBoolEnv(value: string | undefined): boolean {
return /^(1|true|yes|on)$/i.test(value ?? "");
}
@@ -192,21 +200,26 @@ export function parsePlatformList(value: string): Set<Platform> {
return result;
}
export function resolveLatestVersion(versionOverride = ""): string {
export function resolveLatestVersion(
versionOverride = "",
deps: ResolveLatestVersionDeps = {},
): string {
if (versionOverride) {
return versionOverride;
}
return run(
"npm",
[
"view",
"openclaw",
"version",
"--userconfig",
mkdtempSync(path.join(tmpdir(), "openclaw-npm-")),
],
{
const createTempDir = deps.createTempDir ?? mkdtempSync;
const removeDir = deps.removeDir ?? rmSync;
const runCommand = deps.runCommand ?? run;
const resolveTempDir = deps.tempDir ?? tmpdir;
const writeFile = deps.writeFile ?? writeFileSync;
const userConfigDir = createTempDir(path.join(resolveTempDir(), "openclaw-npm-"));
const userConfigPath = path.join(userConfigDir, "npmrc");
try {
writeFile(userConfigPath, "", "utf8");
return runCommand("npm", ["view", "openclaw", "version", "--userconfig", userConfigPath], {
quiet: true,
},
).stdout.trim();
}).stdout.trim();
} finally {
removeDir(userConfigDir, { force: true, recursive: true });
}
}

View File

@@ -1,8 +1,13 @@
import { die, run } from "./host-command.ts";
import type { SnapshotInfo } from "./types.ts";
const SNAPSHOT_LIST_TIMEOUT_MS = 120_000;
export function resolveSnapshot(vmName: string, hint: string): SnapshotInfo {
const output = run("prlctl", ["snapshot-list", vmName, "--json"], { quiet: true }).stdout;
const output = run("prlctl", ["snapshot-list", vmName, "--json"], {
quiet: true,
timeoutMs: SNAPSHOT_LIST_TIMEOUT_MS,
}).stdout;
const payload = JSON.parse(output) as Record<string, { name?: string; state?: string }>;
let best: SnapshotInfo | null = null;
let bestScore = -1;

View File

@@ -449,6 +449,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
{
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(),
},
);
this.log(result.stdout);
@@ -469,9 +470,14 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
}
this.waitForVmNotRestoring(240);
if (this.snapshot.state === "poweroff") {
waitForVmStatus(this.options.vmName, "stopped", 240);
waitForVmStatus(this.options.vmName, "stopped", 240, {
probeTimeoutMs: () => this.remainingPhaseTimeoutMs(30_000),
});
say(`Start restored poweroff snapshot ${this.snapshot.name}`);
run("prlctl", ["start", this.options.vmName], { quiet: true });
run("prlctl", ["start", this.options.vmName], {
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(120_000),
});
}
}
@@ -481,6 +487,7 @@ class WindowsSmoke extends SmokeRunController<WindowsOptions> {
const status = run("prlctl", ["status", this.options.vmName], {
check: false,
quiet: true,
timeoutMs: this.remainingPhaseTimeoutMs(30_000),
}).stdout;
if (!status.includes(" restoring")) {
return;

View File

@@ -847,6 +847,19 @@ function spawnDaemon(params: {
return child.pid;
}
function sleep(ms: number) {
return new Promise((resolve) => {
setTimeout(resolve, ms);
});
}
function waitForChildExit(child: ChildProcess) {
return new Promise<number | null>((resolve, reject) => {
child.once("error", reject);
child.once("exit", resolve);
});
}
export function readLogTail(logPath: string, maxBytes = LOG_READY_TAIL_BYTES): string {
let stat: fs.Stats;
try {
@@ -1011,50 +1024,122 @@ function writeSutConfig(params: {
return { configPath, stateDir, tempRoot, workspace };
}
async function startLocalSut(params: {
gatewayPort: number;
groupId: string;
mockResponseText: string;
mockPort: number;
outputDir: string;
sutToken: string;
testerId: string;
repoRoot: string;
type StartLocalSutDeps = {
createGatewaySpawnSpec?: typeof createOpenClawGatewaySpawnSpec;
drainUpdates?: typeof drainSutUpdates;
spawnLoggedCommand?: typeof spawnLogged;
waitForOutputReady?: typeof waitForOutput;
writeConfig?: typeof writeSutConfig;
};
export async function startLocalSut(
params: {
gatewayPort: number;
groupId: string;
mockResponseText: string;
mockPort: number;
outputDir: string;
sutToken: string;
testerId: string;
repoRoot: string;
},
deps: StartLocalSutDeps = {},
) {
const drainUpdates = deps.drainUpdates ?? drainSutUpdates;
const writeConfig = deps.writeConfig ?? writeSutConfig;
const spawnLoggedCommand = deps.spawnLoggedCommand ?? spawnLogged;
const waitForOutputReady = deps.waitForOutputReady ?? waitForOutput;
const createGatewaySpawnSpec = deps.createGatewaySpawnSpec ?? createOpenClawGatewaySpawnSpec;
let gateway: ReturnType<typeof spawnLogged> | undefined;
let mock: ReturnType<typeof spawnLogged> | undefined;
try {
const drained = await drainUpdates(params.sutToken);
const config = writeConfig(params);
const requestLog = path.join(params.outputDir, "mock-openai-requests.ndjson");
mock = spawnLoggedCommand("node", ["scripts/e2e/mock-openai-server.mjs"], {
cwd: params.repoRoot,
env: mockServerEnv({ ...params, requestLog }),
});
await waitForOutputReady(
mock.child,
/mock-openai listening/u,
() => mock.output,
"mock-openai",
10_000,
);
const gatewaySpec = createGatewaySpawnSpec({
env: gatewayEnv({ ...config, sutToken: params.sutToken }),
gatewayPort: params.gatewayPort,
repoRoot: params.repoRoot,
});
gateway = spawnLoggedCommand(gatewaySpec.command, gatewaySpec.args, gatewaySpec.options);
await waitForOutputReady(
gateway.child,
/\[gateway\] ready/u,
() => gateway.output,
"gateway",
60_000,
);
return {
...config,
drained,
gateway: gateway.child,
get gatewayLog() {
return gateway.output;
},
mock: mock.child,
get mockLog() {
return mock.output;
},
requestLog,
};
} catch (error) {
killTree(gateway?.child);
killTree(mock?.child);
throw error;
}
}
export async function recordProbeVideo(params: {
crabboxBin: string;
cwd: string;
durationSeconds: number;
leaseId: string;
outputPath: string;
provider: string;
runProbe: () => Promise<void>;
startDelayMs?: number;
target: string;
}) {
const drained = await drainSutUpdates(params.sutToken);
const config = writeSutConfig(params);
const requestLog = path.join(params.outputDir, "mock-openai-requests.ndjson");
const mock = spawnLogged("node", ["scripts/e2e/mock-openai-server.mjs"], {
cwd: params.repoRoot,
env: mockServerEnv({ ...params, requestLog }),
});
await waitForOutput(
mock.child,
/mock-openai listening/u,
() => mock.output,
"mock-openai",
10_000,
);
const gatewaySpec = createOpenClawGatewaySpawnSpec({
env: gatewayEnv({ ...config, sutToken: params.sutToken }),
gatewayPort: params.gatewayPort,
repoRoot: params.repoRoot,
});
const gateway = spawnLogged(gatewaySpec.command, gatewaySpec.args, gatewaySpec.options);
await waitForOutput(gateway.child, /\[gateway\] ready/u, () => gateway.output, "gateway", 60_000);
return {
...config,
drained,
gateway: gateway.child,
get gatewayLog() {
return gateway.output;
},
mock: mock.child,
get mockLog() {
return mock.output;
},
requestLog,
};
let recording: ChildProcess | undefined;
try {
recording = spawn(
params.crabboxBin,
[
"artifacts",
"video",
"--provider",
params.provider,
"--target",
params.target,
"--id",
params.leaseId,
"--duration",
`${params.durationSeconds}s`,
"--output",
params.outputPath,
],
{ cwd: params.cwd, stdio: "inherit" },
);
await sleep(params.startDelayMs ?? 3_000);
await params.runProbe();
const recordCode = await waitForChildExit(recording);
if (recordCode !== 0) {
throw new Error(`Crabbox recording failed with exit code ${recordCode ?? "unknown"}.`);
}
} finally {
killTree(recording);
}
}
async function startLocalSutDaemon(params: {
@@ -2512,34 +2597,18 @@ async function main() {
await sshRun(root, inspect, `bash ${REMOTE_ROOT}/authorize-desktop.sh`);
await sshRun(root, inspect, `bash ${REMOTE_ROOT}/select-desktop-chat.sh`);
const videoPath = path.join(outputDir, "telegram-user-crabbox-proof.mp4");
const recording = spawn(
opts.crabboxBin,
[
"artifacts",
"video",
"--provider",
opts.provider,
"--target",
opts.target,
"--id",
leaseId,
"--duration",
`${opts.recordSeconds}s`,
"--output",
videoPath,
],
{ cwd: root, stdio: "inherit" },
);
await new Promise((resolve) => {
setTimeout(resolve, 3_000);
await recordProbeVideo({
crabboxBin: opts.crabboxBin,
cwd: root,
durationSeconds: opts.recordSeconds,
leaseId,
outputPath: videoPath,
provider: opts.provider,
runProbe: async () => {
await sshRun(root, inspect, `bash ${REMOTE_ROOT}/remote-probe.sh`);
},
target: opts.target,
});
await sshRun(root, inspect, `bash ${REMOTE_ROOT}/remote-probe.sh`);
const recordCode = await new Promise<number | null>((resolve) => {
recording.on("exit", resolve);
});
if (recordCode !== 0) {
throw new Error(`Crabbox recording failed with exit code ${recordCode ?? "unknown"}.`);
}
const motionVideoPath = path.join(outputDir, "telegram-user-crabbox-proof-motion.mp4");
const motionGifPath = path.join(outputDir, "telegram-user-crabbox-proof-motion.gif");
summary.mediaPreview = await createMotionPreview({

View File

@@ -410,6 +410,16 @@
"systemImage": "number",
"markdownCapable": true
},
"channelConfigs": {
"slack": {
"label": "Slack",
"description": "Slack channel, DM, command, and app event integration.",
"schema": {
"type": "object",
"additionalProperties": true
}
}
},
"install": {
"npmSpec": "@openclaw/slack",
"defaultChoice": "npm",

View File

@@ -40,14 +40,26 @@ function parseQaLabUpArgs(argv: readonly string[]) {
export const qaLabUpTesting = {
parseQaLabUpArgs,
runQaLabUp,
usage,
};
async function main(argv: readonly string[]): Promise<number> {
type QaLabRuntime = typeof import("../extensions/qa-lab/src/cli.runtime.ts");
type QaLabUpDeps = {
loadRuntime?: () => Promise<Pick<QaLabRuntime, "runQaDockerUpCommand">>;
writeStdout?: (text: string) => void;
};
async function loadQaLabRuntime(): Promise<Pick<QaLabRuntime, "runQaDockerUpCommand">> {
return await import("../extensions/qa-lab/src/cli.runtime.ts");
}
async function runQaLabUp(argv: readonly string[], deps: QaLabUpDeps = {}): Promise<number> {
const values = parseQaLabUpArgs(argv);
if (values.help) {
process.stdout.write(usage());
(deps.writeStdout ?? ((text: string) => process.stdout.write(text)))(usage());
return 0;
}
@@ -62,7 +74,7 @@ async function main(argv: readonly string[]): Promise<number> {
return parsed;
};
const { runQaDockerUpCommand } = await import("../extensions/qa-lab/src/cli.runtime.ts");
const { runQaDockerUpCommand } = await (deps.loadRuntime ?? loadQaLabRuntime)();
await runQaDockerUpCommand({
outputDir: values["output-dir"],
@@ -77,6 +89,10 @@ async function main(argv: readonly string[]): Promise<number> {
return 0;
}
async function main(argv: readonly string[]): Promise<number> {
return await runQaLabUp(argv);
}
if (resolve(process.argv[1] ?? "") === fileURLToPath(import.meta.url)) {
main(process.argv.slice(2)).then(
(code) => {

View File

@@ -867,12 +867,38 @@ async function stopDockerContainer(name: string): Promise<void> {
});
}
async function startDockerOtelCollector(receiverPort: number) {
const collectorPort = await reserveLocalPort();
const tempDir = await mkdtemp(path.join(tmpdir(), "openclaw-otel-collector-"));
type StartDockerOtelCollectorDeps = {
mkdtemp?: typeof mkdtemp;
platform?: NodeJS.Platform;
randomUUID?: typeof randomUUID;
reserveLocalPort?: typeof reserveLocalPort;
rm?: typeof rm;
spawn?: typeof spawn;
stopDockerContainer?: typeof stopDockerContainer;
tmpdir?: typeof tmpdir;
waitForLocalPort?: typeof waitForLocalPort;
writeFile?: typeof writeFile;
};
async function startDockerOtelCollector(
receiverPort: number,
deps: StartDockerOtelCollectorDeps = {},
) {
const reservePort = deps.reserveLocalPort ?? reserveLocalPort;
const makeTempDir = deps.mkdtemp ?? mkdtemp;
const writeConfigFile = deps.writeFile ?? writeFile;
const spawnProcess = deps.spawn ?? spawn;
const waitForPort = deps.waitForLocalPort ?? waitForLocalPort;
const stopContainer = deps.stopDockerContainer ?? stopDockerContainer;
const removePath = deps.rm ?? rm;
const makeUuid = deps.randomUUID ?? randomUUID;
const osTmpdir = deps.tmpdir ?? tmpdir;
const collectorPort = await reservePort();
const tempDir = await makeTempDir(path.join(osTmpdir(), "openclaw-otel-collector-"));
const configPath = path.join(tempDir, "collector.yaml");
const containerName = `openclaw-otel-smoke-${randomUUID()}`;
const useHostNetwork = process.platform === "linux";
const containerName = `openclaw-otel-smoke-${makeUuid()}`;
const useHostNetwork = (deps.platform ?? process.platform) === "linux";
const collectorEndpoint = useHostNetwork ? `127.0.0.1:${collectorPort}` : "0.0.0.0:4318";
const receiverEndpoint = useHostNetwork
? `http://127.0.0.1:${receiverPort}`
@@ -897,7 +923,7 @@ service:
receivers: [otlp]
exporters: [otlphttp/openclaw]
`;
await writeFile(configPath, config, "utf8");
await writeConfigFile(configPath, config, "utf8");
const stdout: string[] = [];
const stderr: string[] = [];
@@ -916,7 +942,7 @@ service:
DEFAULT_DOCKER_COLLECTOR_IMAGE,
"--config=/etc/otelcol/config.yaml",
];
const child = spawn("docker", dockerArgs, { stdio: ["ignore", "pipe", "pipe"] });
const child = spawnProcess("docker", dockerArgs, { stdio: ["ignore", "pipe", "pipe"] });
child.stdout?.on("data", (chunk) => stdout.push(String(chunk)));
child.stderr?.on("data", (chunk) => stderr.push(String(chunk)));
child.on("error", (err) => {
@@ -927,13 +953,22 @@ service:
exitCode = code ?? 1;
});
await waitForLocalPort(collectorPort, 60_000, () => {
if (exitCode === null) {
return "";
try {
await waitForPort(collectorPort, 60_000, () => {
if (exitCode === null) {
return "";
}
const output = [...stdout, ...stderr].join("").trim();
return `OpenTelemetry Collector exited before readiness (code=${exitCode})${output ? `:\n${output}` : ""}`;
});
} catch (error) {
try {
await stopContainer(containerName);
} finally {
await removePath(tempDir, { force: true, recursive: true });
}
const output = [...stdout, ...stderr].join("").trim();
return `OpenTelemetry Collector exited before readiness (code=${exitCode})${output ? `:\n${output}` : ""}`;
});
throw error;
}
return {
port: collectorPort,
@@ -943,8 +978,8 @@ service:
return tailText([...stdout, ...stderr].join("").trim(), COLLECTOR_OUTPUT_TAIL_BYTES);
},
async close(): Promise<void> {
await stopDockerContainer(containerName);
await rm(tempDir, { force: true, recursive: true });
await stopContainer(containerName);
await removePath(tempDir, { force: true, recursive: true });
},
};
}
@@ -1524,6 +1559,7 @@ export const testing = {
parseArgs,
readPositiveIntegerEnv,
readRequestBody,
startDockerOtelCollector,
terminateChildTree,
waitForChild,
};

View File

@@ -90,6 +90,22 @@ const EXTENSION_ACTIVE_MEMORY_VITEST_CONFIG =
const EXTENSION_ACPX_VITEST_CONFIG = "test/vitest/vitest.extension-acpx.config.ts";
const EXTENSION_BROWSER_VITEST_CONFIG = "test/vitest/vitest.extension-browser.config.ts";
const EXTENSION_CODEX_VITEST_CONFIG = "test/vitest/vitest.extension-codex.config.ts";
const EXTENSION_CODEX_APP_SERVER_ATTEMPT_VITEST_CONFIG =
"test/vitest/vitest.extension-codex-app-server-attempt.config.ts";
const EXTENSION_CODEX_APP_SERVER_ATTEMPT_EXTRA_VITEST_CONFIG =
"test/vitest/vitest.extension-codex-app-server-attempt-extra.config.ts";
const EXTENSION_CODEX_APP_SERVER_ATTEMPT_LIGHT_VITEST_CONFIG =
"test/vitest/vitest.extension-codex-app-server-attempt-light.config.ts";
const EXTENSION_CODEX_APP_SERVER_ATTEMPT_SUPPORT_VITEST_CONFIG =
"test/vitest/vitest.extension-codex-app-server-attempt-support.config.ts";
const EXTENSION_CODEX_APP_SERVER_RUNTIME_VITEST_CONFIG =
"test/vitest/vitest.extension-codex-app-server-runtime.config.ts";
const EXTENSION_CODEX_APP_SERVER_SUPPORT_VITEST_CONFIG =
"test/vitest/vitest.extension-codex-app-server-support.config.ts";
const EXTENSION_CODEX_APP_SERVER_TOOLS_VITEST_CONFIG =
"test/vitest/vitest.extension-codex-app-server-tools.config.ts";
const EXTENSION_CODEX_SURFACE_VITEST_CONFIG =
"test/vitest/vitest.extension-codex-surface.config.ts";
const EXTENSION_CHANNELS_VITEST_CONFIG = "test/vitest/vitest.extension-channels.config.ts";
const EXTENSION_DIFFS_VITEST_CONFIG = "test/vitest/vitest.extension-diffs.config.ts";
const EXTENSION_DISCORD_VITEST_CONFIG = "test/vitest/vitest.extension-discord.config.ts";
@@ -148,6 +164,14 @@ const FULL_SUITE_CONFIG_WEIGHT = new Map([
[AGENTS_SUPPORT_VITEST_CONFIG, 168],
[AGENTS_TOOLS_VITEST_CONFIG, 167],
[EXTENSION_CODEX_VITEST_CONFIG, 168],
[EXTENSION_CODEX_APP_SERVER_ATTEMPT_VITEST_CONFIG, 168],
[EXTENSION_CODEX_APP_SERVER_ATTEMPT_EXTRA_VITEST_CONFIG, 118],
[EXTENSION_CODEX_APP_SERVER_ATTEMPT_LIGHT_VITEST_CONFIG, 82],
[EXTENSION_CODEX_APP_SERVER_ATTEMPT_SUPPORT_VITEST_CONFIG, 80],
[EXTENSION_CODEX_APP_SERVER_RUNTIME_VITEST_CONFIG, 88],
[EXTENSION_CODEX_APP_SERVER_TOOLS_VITEST_CONFIG, 78],
[EXTENSION_CODEX_APP_SERVER_SUPPORT_VITEST_CONFIG, 72],
[EXTENSION_CODEX_SURFACE_VITEST_CONFIG, 68],
[EXTENSION_VOICE_CALL_VITEST_CONFIG, 169],
[EXTENSIONS_VITEST_CONFIG, 168],
[EXTENSION_PROVIDER_OPENAI_VITEST_CONFIG, 167],
@@ -455,7 +479,10 @@ const TOOLING_SOURCE_TEST_TARGETS = new Map([
],
[
"scripts/mcp-code-mode-gateway-e2e.ts",
["test/scripts/mcp-code-mode-gateway-client.test.ts", "test/scripts/session-log-mentions.test.ts"],
[
"test/scripts/mcp-code-mode-gateway-client.test.ts",
"test/scripts/session-log-mentions.test.ts",
],
],
["scripts/dependency-changes-report.mjs", ["test/scripts/dependency-changes-report.test.ts"]],
[

View File

@@ -14,6 +14,7 @@ const WATCH_RESTARTABLE_CHILD_SIGNALS = new Set(["SIGTERM"]);
const WATCH_IGNORED_PATH_SEGMENTS = new Set([".git", "dist", "node_modules"]);
const WATCH_LOCK_WAIT_MS = 5_000;
const WATCH_LOCK_POLL_MS = 100;
const WATCH_SHUTDOWN_KILL_GRACE_MS = 5_000;
const WATCH_LOCK_DIR = path.join(".local", "watch-node");
const AUTO_DOCTOR_DISABLE_VALUES = new Set(["0", "false", "no", "off"]);
@@ -292,12 +293,17 @@ export async function runWatchMain(params = {}) {
let watcher = null;
let lockHandle = null;
let autoDoctorAttempted = false;
let shutdownExitCode = null;
let shutdownKillTimer = null;
const settle = (code) => {
if (settled) {
return;
}
settled = true;
if (shutdownKillTimer) {
clearTimeout(shutdownKillTimer);
}
if (onSigInt) {
deps.process.off("SIGINT", onSigInt);
}
@@ -309,6 +315,30 @@ export async function runWatchMain(params = {}) {
resolve(code);
};
const requestShutdown = (code) => {
shuttingDown = true;
shutdownExitCode = code;
if (!watchProcess || typeof watchProcess.kill !== "function") {
settle(code);
return;
}
watchProcess.kill(WATCH_RESTART_SIGNAL);
shutdownKillTimer ??= setTimeout(() => {
shutdownKillTimer = null;
if (watchProcess && typeof watchProcess.kill === "function") {
watchProcess.kill("SIGKILL");
}
}, WATCH_SHUTDOWN_KILL_GRACE_MS);
};
const settleIfShuttingDown = () => {
if (!shuttingDown || shutdownExitCode === null) {
return false;
}
settle(shutdownExitCode);
return true;
};
const startRunner = () => {
watchProcess = deps.spawn(deps.process.execPath, buildRunnerArgs(deps.args), {
cwd: deps.cwd,
@@ -322,7 +352,10 @@ export async function runWatchMain(params = {}) {
});
watchProcess.on("exit", (exitCode, exitSignal) => {
watchProcess = null;
if (shuttingDown) {
if (settled) {
return;
}
if (settleIfShuttingDown()) {
return;
}
if (restartRequested || shouldRestartAfterChildExit(exitCode, exitSignal)) {
@@ -339,11 +372,7 @@ export async function runWatchMain(params = {}) {
};
const handleWatcherError = () => {
shuttingDown = true;
if (watchProcess && typeof watchProcess.kill === "function") {
watchProcess.kill(WATCH_RESTART_SIGNAL);
}
settle(1);
requestShutdown(1);
};
const rejectWatcherStartupError = (err) => {
@@ -396,7 +425,10 @@ export async function runWatchMain(params = {}) {
});
watchProcess.on("exit", (exitCode, exitSignal) => {
watchProcess = null;
if (shuttingDown) {
if (settled) {
return;
}
if (settleIfShuttingDown()) {
return;
}
if (exitCode === 0 && !exitSignal) {
@@ -450,18 +482,10 @@ export async function runWatchMain(params = {}) {
};
const onSigInt = () => {
shuttingDown = true;
if (watchProcess && typeof watchProcess.kill === "function") {
watchProcess.kill(WATCH_RESTART_SIGNAL);
}
settle(130);
requestShutdown(130);
};
const onSigTerm = () => {
shuttingDown = true;
if (watchProcess && typeof watchProcess.kill === "function") {
watchProcess.kill(WATCH_RESTART_SIGNAL);
}
settle(143);
requestShutdown(143);
};
deps.process.on("SIGINT", onSigInt);

View File

@@ -35,6 +35,22 @@ type ResolvePnpmCommandOptions = {
};
const COMMAND_OUTPUT_MAX_CHARS = 512 * 1024;
type ReproLog = (message: string) => void;
type RunCommand = typeof runCommand;
type RunZaiFallbackReproDeps = {
env?: NodeJS.ProcessEnv;
error?: ReproLog;
log?: ReproLog;
mkdtemp?: typeof fs.mkdtemp;
mkdir?: typeof fs.mkdir;
randomUUID?: typeof randomUUID;
readFile?: typeof fs.readFile;
rm?: typeof fs.rm;
runCommand?: RunCommand;
warn?: ReproLog;
writeFile?: typeof fs.writeFile;
};
function resolveEnvValue(env: NodeJS.ProcessEnv, name: string): string | undefined {
const key = Object.keys(env).find((candidate) => candidate.toLowerCase() === name.toLowerCase());
@@ -81,20 +97,20 @@ export function resolveZaiFallbackPnpmCommand(
return command;
}
function pickAnthropicEnv(): { type: "oauth" | "api"; value: string } | null {
const oauth = process.env.ANTHROPIC_OAUTH_TOKEN?.trim();
function pickAnthropicEnv(env: NodeJS.ProcessEnv): { type: "oauth" | "api"; value: string } | null {
const oauth = env.ANTHROPIC_OAUTH_TOKEN?.trim();
if (oauth) {
return { type: "oauth", value: oauth };
}
const api = process.env.ANTHROPIC_API_KEY?.trim();
const api = env.ANTHROPIC_API_KEY?.trim();
if (api) {
return { type: "api", value: api };
}
return null;
}
function pickZaiKey(): string | null {
return process.env.ZAI_API_KEY?.trim() ?? process.env.Z_AI_API_KEY?.trim() ?? null;
function pickZaiKey(env: NodeJS.ProcessEnv): string | null {
return env.ZAI_API_KEY?.trim() ?? env.Z_AI_API_KEY?.trim() ?? null;
}
async function runCommand(
@@ -143,97 +159,116 @@ async function runCommand(
});
}
async function main() {
const anthropic = pickAnthropicEnv();
const zaiKey = pickZaiKey();
export async function runZaiFallbackRepro(deps: RunZaiFallbackReproDeps = {}): Promise<number> {
const env = deps.env ?? process.env;
const log = deps.log ?? console.log;
const warn = deps.warn ?? console.warn;
const error = deps.error ?? console.error;
const mkdtemp = deps.mkdtemp ?? fs.mkdtemp;
const mkdir = deps.mkdir ?? fs.mkdir;
const readFile = deps.readFile ?? fs.readFile;
const rm = deps.rm ?? fs.rm;
const writeFile = deps.writeFile ?? fs.writeFile;
const run = deps.runCommand ?? runCommand;
const createUuid = deps.randomUUID ?? randomUUID;
const anthropic = pickAnthropicEnv(env);
const zaiKey = pickZaiKey(env);
if (!anthropic) {
console.error("Missing ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY.");
process.exit(1);
error("Missing ANTHROPIC_OAUTH_TOKEN or ANTHROPIC_API_KEY.");
return 1;
}
if (!zaiKey) {
console.error("Missing ZAI_API_KEY or Z_AI_API_KEY.");
process.exit(1);
error("Missing ZAI_API_KEY or Z_AI_API_KEY.");
return 1;
}
const baseDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-zai-fallback-"));
const baseDir = await mkdtemp(path.join(os.tmpdir(), "openclaw-zai-fallback-"));
const stateDir = path.join(baseDir, "state");
const configPath = path.join(baseDir, "openclaw.json");
await fs.mkdir(stateDir, { recursive: true });
try {
await mkdir(stateDir, { recursive: true });
const config = {
agents: {
defaults: {
model: {
primary: "anthropic/claude-opus-4-6",
fallbacks: ["zai/glm-4.7"],
},
models: {
"anthropic/claude-opus-4-6": {},
"anthropic/claude-opus-4-5": {},
"zai/glm-4.7": {},
const config = {
agents: {
defaults: {
model: {
primary: "anthropic/claude-opus-4-6",
fallbacks: ["zai/glm-4.7"],
},
models: {
"anthropic/claude-opus-4-6": {},
"anthropic/claude-opus-4-5": {},
"zai/glm-4.7": {},
},
},
},
},
};
await fs.writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
};
await writeFile(configPath, JSON.stringify(config, null, 2), "utf8");
const sessionId = process.env.OPENCLAW_ZAI_FALLBACK_SESSION_ID ?? randomUUID();
const sessionId = env.OPENCLAW_ZAI_FALLBACK_SESSION_ID ?? createUuid();
const baseEnv: NodeJS.ProcessEnv = {
...process.env,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: stateDir,
ZAI_API_KEY: zaiKey,
Z_AI_API_KEY: "",
};
const baseEnv: NodeJS.ProcessEnv = {
...env,
OPENCLAW_CONFIG_PATH: configPath,
OPENCLAW_STATE_DIR: stateDir,
ZAI_API_KEY: zaiKey,
Z_AI_API_KEY: "",
};
const envValidAnthropic: NodeJS.ProcessEnv = {
...baseEnv,
ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? anthropic.value : "",
ANTHROPIC_API_KEY: anthropic.type === "api" ? anthropic.value : "",
};
const envValidAnthropic: NodeJS.ProcessEnv = {
...baseEnv,
ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? anthropic.value : "",
ANTHROPIC_API_KEY: anthropic.type === "api" ? anthropic.value : "",
};
const envInvalidAnthropic: NodeJS.ProcessEnv = {
...baseEnv,
ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? "invalid" : "",
ANTHROPIC_API_KEY: anthropic.type === "api" ? "invalid" : "",
};
const envInvalidAnthropic: NodeJS.ProcessEnv = {
...baseEnv,
ANTHROPIC_OAUTH_TOKEN: anthropic.type === "oauth" ? "invalid" : "",
ANTHROPIC_API_KEY: anthropic.type === "api" ? "invalid" : "",
};
console.log("== Run 1: create tool history (primary only)");
const toolPrompt =
"Use the exec tool to create a file named zai-fallback-tool.txt with the content tool-ok. " +
"Then use the read tool to display the file contents. Reply with just the file contents.";
const run1 = await runCommand(
"run1",
["openclaw", "agent", "--local", "--session-id", sessionId, "--message", toolPrompt],
envValidAnthropic,
);
if (run1.code !== 0) {
process.exit(run1.code ?? 1);
log("== Run 1: create tool history (primary only)");
const toolPrompt =
"Use the exec tool to create a file named zai-fallback-tool.txt with the content tool-ok. " +
"Then use the read tool to display the file contents. Reply with just the file contents.";
const run1 = await run(
"run1",
["openclaw", "agent", "--local", "--session-id", sessionId, "--message", toolPrompt],
envValidAnthropic,
);
if (run1.code !== 0) {
return run1.code ?? 1;
}
const sessionFile = path.join(stateDir, "agents", "main", "sessions", `${sessionId}.jsonl`);
const transcript = await readFile(sessionFile, "utf8").catch(() => "");
if (!transcript.includes('"toolResult"')) {
warn("Warning: no toolResult entries detected in session history.");
}
log("== Run 2: force auth failover to Z.AI");
const followupPrompt =
"What is the content of zai-fallback-tool.txt? Reply with just the contents.";
const run2 = await run(
"run2",
["openclaw", "agent", "--local", "--session-id", sessionId, "--message", followupPrompt],
envInvalidAnthropic,
);
if (run2.code === 0) {
log("PASS: fallback succeeded.");
return 0;
}
error("FAIL: fallback failed.");
return run2.code ?? 1;
} finally {
await rm(baseDir, { force: true, recursive: true });
}
}
const sessionFile = path.join(stateDir, "agents", "main", "sessions", `${sessionId}.jsonl`);
const transcript = await fs.readFile(sessionFile, "utf8").catch(() => "");
if (!transcript.includes('"toolResult"')) {
console.warn("Warning: no toolResult entries detected in session history.");
}
console.log("== Run 2: force auth failover to Z.AI");
const followupPrompt =
"What is the content of zai-fallback-tool.txt? Reply with just the contents.";
const run2 = await runCommand(
"run2",
["openclaw", "agent", "--local", "--session-id", sessionId, "--message", followupPrompt],
envInvalidAnthropic,
);
if (run2.code === 0) {
console.log("PASS: fallback succeeded.");
process.exit(0);
}
console.error("FAIL: fallback failed.");
process.exit(run2.code ?? 1);
async function main() {
process.exitCode = await runZaiFallbackRepro();
}
function isCliEntrypoint() {

View File

@@ -28,6 +28,11 @@ function workspacePathsOverlap(left: string, right: string): boolean {
);
}
/**
* Find other configured agents whose workspaces overlap the target deletion
* workspace. Deletion callers use this to avoid removing shared parent/child
* directories that still belong to another agent.
*/
export function findOverlappingWorkspaceAgentIds(
cfg: OpenClawConfig,
agentId: string,

View File

@@ -5,6 +5,11 @@ import { normalizeProviderId } from "./model-selection.js";
const CLAUDE_CLI_BACKEND_ID = "claude-cli";
/**
* Hash CLI-session reuse inputs before persisting them into session metadata.
* The stored value is only an equality token, so prompt/cwd/MCP inputs are not
* written back into the session store in plaintext.
*/
export function hashCliSessionText(value: string | undefined): string | undefined {
const trimmed = normalizeOptionalString(value);
if (!trimmed) {
@@ -13,6 +18,11 @@ export function hashCliSessionText(value: string | undefined): string | undefine
return crypto.createHash("sha256").update(trimmed).digest("hex");
}
/**
* Resolve the stored CLI session binding for a provider. New structured
* bindings win, older provider-id maps are still read, and the legacy
* Claude-only field is retained as a final migration fallback.
*/
export function getCliSessionBinding(
entry: SessionEntry | undefined,
provider: string,
@@ -51,6 +61,7 @@ export function getCliSessionBinding(
return undefined;
}
/** Return only the reusable CLI session id for callers that do not need invalidation metadata. */
export function getCliSessionId(
entry: SessionEntry | undefined,
provider: string,
@@ -58,10 +69,19 @@ export function getCliSessionId(
return getCliSessionBinding(entry, provider)?.sessionId;
}
/**
* Store a CLI session id without reuse metadata. Prefer `setCliSessionBinding`
* when the caller can also persist auth, prompt, cwd, or MCP hashes.
*/
export function setCliSessionId(entry: SessionEntry, provider: string, sessionId: string): void {
setCliSessionBinding(entry, provider, { sessionId });
}
/**
* Persist a provider-scoped CLI session binding in all currently supported
* session-store shapes. The duplicate legacy writes keep older readers working
* while structured bindings carry the invalidation inputs for newer runtimes.
*/
export function setCliSessionBinding(
entry: SessionEntry,
provider: string,
@@ -109,6 +129,11 @@ export function setCliSessionBinding(
}
}
/**
* Clear one provider's CLI session binding across structured and legacy fields.
* Other providers' bindings stay intact so a model switch only invalidates the
* backend that actually failed or changed reuse conditions.
*/
export function clearCliSession(entry: SessionEntry, provider: string): void {
const normalized = normalizeProviderId(provider);
if (entry.cliSessionBindings?.[normalized] !== undefined) {
@@ -126,12 +151,18 @@ export function clearCliSession(entry: SessionEntry, provider: string): void {
}
}
/** Clear every persisted CLI session binding from a session entry. */
export function clearAllCliSessions(entry: SessionEntry): void {
entry.cliSessionBindings = undefined;
entry.cliSessionIds = undefined;
entry.claudeCliSessionId = undefined;
}
/**
* Decide whether a stored CLI session can be reused under the current run
* inputs. Auth, system prompt, cwd, and MCP changes invalidate the session
* unless the binding was explicitly marked `forceReuse`.
*/
export function resolveCliSessionReuse(params: {
binding?: CliSessionBinding;
authProfileId?: string;
@@ -163,6 +194,8 @@ export function resolveCliSessionReuse(params: {
const currentMcpResumeHash = normalizeOptionalString(params.mcpResumeHash);
const storedAuthProfileId = normalizeOptionalString(binding?.authProfileId);
const storedAuthEpoch = normalizeOptionalString(binding?.authEpoch);
// Versioned auth epochs let a rotated profile keep reuse when the underlying
// auth material is known to be unchanged, avoiding unnecessary CLI restarts.
const hasMatchingVersionedAuthEpoch =
binding?.authEpochVersion === params.authEpochVersion &&
storedAuthEpoch !== undefined &&

View File

@@ -1,5 +1,5 @@
import { MAX_TIMER_TIMEOUT_MS } from "@openclaw/normalization-core/number-coercion";
import { afterEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { withEnvAsync } from "../test-utils/env.js";
import { withFetchPreconnect } from "../test-utils/fetch-mock.js";
import { scanOpenRouterModels } from "./model-scan.js";
@@ -15,8 +15,14 @@ function createFetchFixture(payload: unknown): typeof fetch {
}
describe("scanOpenRouterModels", () => {
beforeEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
it("lists free models without probing", async () => {
@@ -108,6 +114,7 @@ describe("scanOpenRouterModels", () => {
});
it("applies the scan timeout to the OpenRouter catalog request", async () => {
vi.useFakeTimers();
const fetchImpl: typeof fetch = async (_input, init) =>
await new Promise<Response>((_resolve, reject) => {
const signal = typeof init === "object" && init ? init.signal : undefined;
@@ -120,13 +127,16 @@ describe("scanOpenRouterModels", () => {
});
});
await expect(
const scan = expect(
scanOpenRouterModels({
fetchImpl,
probe: false,
timeoutMs: 1,
}),
).rejects.toThrow(/catalog aborted/);
await vi.advanceTimersByTimeAsync(1);
await scan;
});
it("caps oversized scan timeouts before scheduling catalog aborts", async () => {

View File

@@ -6,6 +6,7 @@ const pluginRegistryMocks = vi.hoisted(() => {
loadPluginManifestRegistryForInstalledIndex: loadManifestRegistry,
loadPluginManifestRegistryForPluginRegistry: loadManifestRegistry,
loadPluginRegistrySnapshot: vi.fn(() => ({ plugins: [] })),
resolveInstalledManifestRegistryIndexFingerprint: vi.fn(() => "test-index"),
loadPluginMetadataSnapshot: vi.fn((params: unknown) => {
const registry = loadManifestRegistry(params) ?? { plugins: [], diagnostics: [] };
return {
@@ -26,6 +27,8 @@ const pluginRegistryMocks = vi.hoisted(() => {
vi.mock("../plugins/manifest-registry-installed.js", () => ({
loadPluginManifestRegistryForInstalledIndex:
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex,
resolveInstalledManifestRegistryIndexFingerprint:
pluginRegistryMocks.resolveInstalledManifestRegistryIndexFingerprint,
}));
vi.mock("../plugins/plugin-registry.js", () => ({
@@ -38,13 +41,104 @@ vi.mock("../plugins/plugin-metadata-snapshot.js", () => ({
loadPluginMetadataSnapshot: pluginRegistryMocks.loadPluginMetadataSnapshot,
}));
import {
clearCurrentPluginMetadataSnapshot,
setCurrentPluginMetadataSnapshot,
} from "../plugins/current-plugin-metadata-snapshot.js";
import type { InstalledPluginIndexRecord } from "../plugins/installed-plugin-index.js";
import { resolveInstalledPluginIndexPolicyHash } from "../plugins/installed-plugin-index-policy.js";
import type { PluginManifestRecord } from "../plugins/manifest-registry.js";
import type { PluginMetadataSnapshot } from "../plugins/plugin-metadata-snapshot.types.js";
import {
resetProviderAuthAliasMapCacheForTest,
resolveProviderIdForAuth,
} from "./provider-auth-aliases.js";
function createPluginManifestRecord(
plugin: Partial<PluginManifestRecord> & Pick<PluginManifestRecord, "id" | "origin">,
): PluginManifestRecord {
return {
channels: [],
providers: [],
cliBackends: [],
skills: [],
hooks: [],
rootDir: `/plugins/${plugin.id}`,
source: `/plugins/${plugin.id}`,
manifestPath: `/plugins/${plugin.id}/.codex-plugin/plugin.json`,
...plugin,
};
}
function createInstalledPluginIndexRecord(
plugin: PluginManifestRecord,
): InstalledPluginIndexRecord {
return {
pluginId: plugin.id,
manifestPath: plugin.manifestPath,
manifestHash: `${plugin.id}:manifest`,
rootDir: plugin.rootDir,
origin: plugin.origin,
enabled: true,
enabledByDefault: true,
startup: {
sidecar: false,
memory: false,
deferConfiguredChannelFullLoadUntilAfterListen: false,
agentHarnesses: [],
},
compat: [],
};
}
function createPluginMetadataSnapshot(params: {
config?: Parameters<typeof resolveInstalledPluginIndexPolicyHash>[0];
plugins: readonly PluginManifestRecord[];
}): PluginMetadataSnapshot {
const policyHash = resolveInstalledPluginIndexPolicyHash(params.config);
return {
policyHash,
index: {
version: 1,
hostContractVersion: "test",
compatRegistryVersion: "test",
migrationVersion: 1,
policyHash,
generatedAtMs: 1,
installRecords: {},
plugins: params.plugins.map((plugin) => createInstalledPluginIndexRecord(plugin)),
diagnostics: [],
},
registryDiagnostics: [],
manifestRegistry: { plugins: [...params.plugins], diagnostics: [] },
plugins: params.plugins,
diagnostics: [],
byPluginId: new Map(params.plugins.map((plugin) => [plugin.id, plugin])),
normalizePluginId: (pluginId) => pluginId,
owners: {
channels: new Map(),
channelConfigs: new Map(),
providers: new Map(),
modelCatalogProviders: new Map(),
cliBackends: new Map(),
setupProviders: new Map(),
commandAliases: new Map(),
contracts: new Map(),
},
metrics: {
registrySnapshotMs: 0,
manifestRegistryMs: 0,
ownerMapsMs: 0,
totalMs: 0,
indexPluginCount: params.plugins.length,
manifestPluginCount: params.plugins.length,
},
};
}
describe("provider auth aliases", () => {
beforeEach(() => {
clearCurrentPluginMetadataSnapshot();
resetProviderAuthAliasMapCacheForTest();
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReset();
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry.mockReset();
@@ -54,9 +148,9 @@ describe("provider auth aliases", () => {
});
it("treats deprecated auth choice ids as provider auth aliases", () => {
pluginRegistryMocks.loadPluginManifestRegistryForInstalledIndex.mockReturnValue({
const metadataSnapshot = createPluginMetadataSnapshot({
plugins: [
{
createPluginManifestRecord({
id: "openai",
origin: "bundled",
providerAuthChoices: [
@@ -67,49 +161,52 @@ describe("provider auth aliases", () => {
deprecatedChoiceIds: ["codex-cli", "openai-chatgpt-import"],
},
],
},
}),
],
diagnostics: [],
});
expect(resolveProviderIdForAuth("codex-cli")).toBe("openai");
expect(resolveProviderIdForAuth("openai-chatgpt-import")).toBe("openai");
expect(resolveProviderIdForAuth("openai")).toBe("openai");
expect(resolveProviderIdForAuth("codex-cli", { metadataSnapshot })).toBe("openai");
expect(resolveProviderIdForAuth("openai-chatgpt-import", { metadataSnapshot })).toBe("openai");
expect(resolveProviderIdForAuth("openai", { metadataSnapshot })).toBe("openai");
});
it("does not reuse aliases across env-resolved plugin roots", () => {
const config = {};
const env = {
HOME: "/home/one",
OPENCLAW_HOME: undefined,
} as NodeJS.ProcessEnv;
pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry
.mockReturnValueOnce({
setCurrentPluginMetadataSnapshot(
createPluginMetadataSnapshot({
config,
plugins: [
{
createPluginManifestRecord({
id: "one",
origin: "global",
providerAuthAliases: { fixture: "provider-one" },
},
}),
],
diagnostics: [],
})
.mockReturnValueOnce({
}),
{ config, env },
);
expect(resolveProviderIdForAuth("fixture", { config, env })).toBe("provider-one");
env.HOME = "/home/two";
setCurrentPluginMetadataSnapshot(
createPluginMetadataSnapshot({
config,
plugins: [
{
createPluginManifestRecord({
id: "two",
origin: "global",
providerAuthAliases: { fixture: "provider-two" },
},
}),
],
diagnostics: [],
});
expect(resolveProviderIdForAuth("fixture", { config: {}, env })).toBe("provider-one");
env.HOME = "/home/two";
expect(resolveProviderIdForAuth("fixture", { config: {}, env })).toBe("provider-two");
expect(pluginRegistryMocks.loadPluginManifestRegistryForPluginRegistry).toHaveBeenCalledTimes(
2,
}),
{ config, env },
);
expect(resolveProviderIdForAuth("fixture", { config, env })).toBe("provider-two");
});
it("uses caller-provided metadata snapshots without loading plugin metadata", () => {

View File

@@ -5,6 +5,11 @@ import { ensureCustomApiRegistered } from "./custom-api-registry.js";
import { createTransportAwareStreamFnForModel } from "./provider-transport-stream.js";
import type { StreamFn } from "./runtime/index.js";
/**
* Resolve and register the stream function for a concrete model. Provider
* plugin streams win, transport-aware built-ins are the fallback, and successful
* resolution updates the custom API registry for downstream runtime dispatch.
*/
export function registerProviderStreamForModel<TApi extends Api>(params: {
model: Model<TApi>;
cfg?: OpenClawConfig;

View File

@@ -6,6 +6,7 @@ const hoisted = vi.hoisted(() => ({
getActivePluginRuntimeSubagentMode: vi.fn<() => "default" | "explicit" | "gateway-bindable">(
() => "default",
),
getActivePluginRegistryWorkspaceDir: vi.fn<() => string | undefined>(() => undefined),
}));
vi.mock("../plugins/current-plugin-metadata-snapshot.js", () => ({
@@ -18,6 +19,7 @@ vi.mock("../plugins/runtime/standalone-runtime-registry-loader.js", () => ({
vi.mock("../plugins/runtime.js", () => ({
getActivePluginRuntimeSubagentMode: hoisted.getActivePluginRuntimeSubagentMode,
getActivePluginRegistryWorkspaceDir: hoisted.getActivePluginRegistryWorkspaceDir,
}));
describe("ensureRuntimePluginsLoaded", () => {
@@ -30,6 +32,8 @@ describe("ensureRuntimePluginsLoaded", () => {
hoisted.ensureStandaloneRuntimePluginRegistryLoaded.mockReturnValue(undefined);
hoisted.getActivePluginRuntimeSubagentMode.mockReset();
hoisted.getActivePluginRuntimeSubagentMode.mockReturnValue("default");
hoisted.getActivePluginRegistryWorkspaceDir.mockReset();
hoisted.getActivePluginRegistryWorkspaceDir.mockReturnValue(undefined);
vi.resetModules();
({ ensureRuntimePluginsLoaded } = await import("./runtime-plugins.js"));
});

View File

@@ -827,7 +827,6 @@ describe("Tool Search", () => {
}, 5_000);
it("aborts already-started bridged calls when code mode times out", async () => {
testing.setToolSearchMinCodeTimeoutMsForTest(100);
const codeTool = fakeTool(TOOL_SEARCH_CODE_MODE_TOOL_NAME, "code mode");
const target = pluginTool("fake_abort_on_timeout", "Long-running target tool");
let observedSignal: AbortSignal | undefined;
@@ -860,7 +859,7 @@ describe("Tool Search", () => {
const config = {
tools: {
toolSearch: { enabled: true, mode: "code", codeTimeoutMs: 100 },
toolSearch: { enabled: true, mode: "code", codeTimeoutMs: 1_000 },
},
} as never;
applyToolSearchCatalog({

View File

@@ -24,40 +24,27 @@ import type {
import type { AnyAgentTool } from "./tools/common.js";
function resolveEffectiveToolLabel(tool: AnyAgentTool): string {
const toolName = readEffectiveToolName(tool);
const rawLabel = normalizeOptionalString(readEffectiveToolField(tool, "label")) ?? "";
const rawLabel = normalizeOptionalString(tool.label) ?? "";
if (
rawLabel &&
normalizeLowercaseStringOrEmpty(rawLabel) !== normalizeLowercaseStringOrEmpty(toolName)
normalizeLowercaseStringOrEmpty(rawLabel) !== normalizeLowercaseStringOrEmpty(tool.name)
) {
return rawLabel;
}
return resolveToolDisplay({ name: toolName }).title;
return resolveToolDisplay({ name: tool.name }).title;
}
function resolveRawToolDescription(tool: AnyAgentTool): string {
return normalizeOptionalString(readEffectiveToolField(tool, "description")) ?? "";
return normalizeOptionalString(tool.description) ?? "";
}
function summarizeToolDescription(tool: AnyAgentTool): string {
return summarizeToolDescriptionText({
rawDescription: resolveRawToolDescription(tool),
displaySummary: normalizeOptionalString(readEffectiveToolField(tool, "displaySummary")),
displaySummary: tool.displaySummary,
});
}
function readEffectiveToolField(tool: AnyAgentTool, field: string): unknown {
try {
return (tool as Record<string, unknown>)[field];
} catch {
return undefined;
}
}
function readEffectiveToolName(tool: AnyAgentTool, fallback = "tool"): string {
return normalizeOptionalString(readEffectiveToolField(tool, "name")) ?? fallback;
}
function resolveEffectiveToolSource(
tool: AnyAgentTool,
fallbackTool?: AnyAgentTool,
@@ -144,10 +131,7 @@ function buildReadableRawToolsByName(
for (let index = 0; index < toolCount; index += 1) {
try {
const tool = tools[index];
const toolName = readEffectiveToolName(tool, "");
if (toolName) {
toolsByName.set(toolName, tool);
}
toolsByName.set(tool.name, tool);
} catch {
// Unreadable entries are reported by the schema projection diagnostics.
}
@@ -184,15 +168,14 @@ export function buildEffectiveToolInventoryEntries(
return disambiguateLabels(
tools
.map((tool, index) => {
const toolName = readEffectiveToolName(tool, `tool[${index}]`);
const source = resolveEffectiveToolSource(tool, rawToolsByName.get(toolName));
.map((tool) => {
const source = resolveEffectiveToolSource(tool, rawToolsByName.get(tool.name));
const metadata = source.pluginId
? pluginToolMetadata.get(buildPluginToolMetadataKey(source.pluginId, toolName))
? pluginToolMetadata.get(buildPluginToolMetadataKey(source.pluginId, tool.name))
: undefined;
return Object.assign(
{
id: toolName,
id: tool.name,
label:
normalizeOptionalString(metadata?.displayName) ?? resolveEffectiveToolLabel(tool),
description:

View File

@@ -861,32 +861,6 @@ describe("resolveEffectiveToolInventory", () => {
});
});
it("falls back to raw description when displaySummary is unreadable", async () => {
const tool = mockTool({
name: "cron",
label: "Cron",
description: "Long raw description\n\nACTIONS:\n- status",
});
Object.defineProperty(tool, "displaySummary", {
get() {
throw new Error("display summary exploded");
},
});
const { resolveEffectiveToolInventory: resolveEffectiveToolInventoryItem } = await loadHarness({
tools: [tool],
});
const result = resolveEffectiveToolInventoryItem({ cfg: {} });
expect(result.groups[0]?.tools[0]).toEqual({
id: "cron",
label: "Cron",
description: "Long raw description",
rawDescription: "Long raw description\n\nACTIONS:\n- status",
source: "core",
});
});
it("falls back to a sanitized summary for multi-line raw descriptions", async () => {
const { resolveEffectiveToolInventory: resolveEffectiveToolInventoryCandidate } =
await loadHarness({

View File

@@ -1758,6 +1758,9 @@ describe("createImageGenerateTool", () => {
const defaultLoadOptions = mockCallArg(webMedia.loadWebMedia, 0, "loadWebMedia", 1);
expect(defaultLoadUrl).toBe("http://198.18.0.153/reference.png");
expect(requireRecord(defaultLoadOptions, "loadWebMedia options").ssrfPolicy).toBeUndefined();
expect(requireRecord(defaultLoadOptions, "loadWebMedia options").readIdleTimeoutMs).toBe(
120_000,
);
const tool = requireImageGenerateTool(
createImageGenerateTool({
@@ -1782,6 +1785,9 @@ describe("createImageGenerateTool", () => {
expect(requireRecord(configuredLoadOptions, "loadWebMedia options").ssrfPolicy).toEqual({
allowRfc2544BenchmarkRange: true,
});
expect(requireRecord(configuredLoadOptions, "loadWebMedia options").readIdleTimeoutMs).toBe(
120_000,
);
expect(mockCallArg(generateImage, 1, "generateImage").ssrfPolicy).toEqual({
allowRfc2544BenchmarkRange: true,
});

View File

@@ -81,6 +81,7 @@ import {
hasGenerationToolAvailability,
normalizeMediaReferenceInputs,
readGenerationTimeoutMs,
REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS,
resolveRemoteMediaSsrfPolicy,
resolveCapabilityModelConfigForTool,
resolveGenerateAction,
@@ -618,6 +619,7 @@ async function loadReferenceImages(params: {
maxBytes: params.maxBytes,
localRoots,
ssrfPolicy: params.ssrfPolicy,
...(isHttpUrl ? { readIdleTimeoutMs: REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS } : {}),
});
if (media.kind !== "image") {
throw new ToolInputError(`Unsupported media type: ${media.kind}`);

View File

@@ -1981,6 +1981,46 @@ describe("image tool implicit imageModel config", () => {
});
});
it("passes the shared remote read idle timeout when loading remote image references", async () => {
const fetch = vi.fn(
async () =>
new Response(
JSON.stringify({ content: "ok", base_resp: { status_code: 0, status_msg: "" } }),
),
);
global.fetch = withFetchPreconnect(fetch);
vi.stubEnv("MINIMAX_API_KEY", "minimax-test");
const loadWebMedia = vi.fn(async () => ({
buffer: Buffer.from(ONE_PIXEL_PNG_B64, "base64"),
contentType: "image/png",
kind: "image" as const,
}));
installImageUnderstandingProviderDeps([minimaxProvider, moonshotProvider], {
loadImageWebMediaRuntime: async () => ({
loadWebMedia,
optimizeImageBufferForWebMedia: async ({ buffer, contentType, fileName }) => ({
buffer,
contentType: contentType ?? "image/png",
kind: "image",
fileName,
}),
}),
});
await withTempAgentDir(async (agentDir) => {
const tool = createRequiredImageTool({
config: createMinimaxImageConfig(),
agentDir,
});
await expectImageToolExecOk(tool, "https://example.test/reference.png");
expect(loadWebMedia).toHaveBeenCalledTimes(1);
const [, options] = fetchCallAt(loadWebMedia, 0);
expect((options as { readIdleTimeoutMs?: number }).readIdleTimeoutMs).toBe(120_000);
});
});
it("sandboxes image paths like the read tool", async () => {
await withTempSandboxState(async ({ agentDir, sandboxRoot }) => {
await fs.writeFile(path.join(sandboxRoot, "img.png"), "fake", "utf8");

View File

@@ -59,6 +59,7 @@ import {
import {
applyImageModelConfigDefaults,
buildTextToolResult,
REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS,
resolveMediaToolInboundRoots,
resolveMediaToolLocalRoots,
resolveRemoteMediaSsrfPolicy,
@@ -90,6 +91,7 @@ type ImageToolLoadWebMediaOptions = {
localRoots?: readonly string[] | "any";
inboundRoots?: readonly string[];
ssrfPolicy?: ReturnType<typeof resolveRemoteMediaSsrfPolicy>;
readIdleTimeoutMs?: number;
};
type ImageWebMediaRuntime = {
@@ -974,6 +976,7 @@ export function createImageTool(options?: {
localRoots: mediaLocalRoots,
inboundRoots: mediaInboundRoots,
ssrfPolicy: remoteMediaSsrfPolicy,
...(isHttpUrl ? { readIdleTimeoutMs: REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS } : {}),
imageCompression,
});
if (media.kind !== "image") {

View File

@@ -70,6 +70,8 @@ type TaskRunDetailHandle = {
runId: string;
};
export const REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS = 120_000;
export function applyImageModelConfigDefaults(
cfg: OpenClawConfig | undefined,
imageModelConfig: ImageModelConfig,

View File

@@ -20,6 +20,7 @@ import { coerceImageModelConfig, type ImageModelConfig } from "./image-tool.help
import {
applyImageModelConfigDefaults,
buildTextToolResult,
REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS,
resolveModelFromRegistry,
resolveMediaToolLocalRoots,
resolveModelRuntimeApiKey,
@@ -54,7 +55,6 @@ const DEFAULT_PROMPT = "Analyze this PDF document.";
const DEFAULT_MAX_PDFS = 10;
const DEFAULT_MAX_BYTES_MB = 10;
const DEFAULT_MAX_PAGES = 20;
const PDF_REMOTE_READ_IDLE_TIMEOUT_MS = 120_000;
const PDF_MIN_TEXT_CHARS = 200;
const PDF_MAX_PIXELS = 4_000_000;
@@ -457,7 +457,7 @@ export function createPdfTool(options?: {
: await loadWebMediaRaw(resolvedPathInfo.resolved, {
maxBytes,
localRoots,
...(isHttpUrl ? { readIdleTimeoutMs: PDF_REMOTE_READ_IDLE_TIMEOUT_MS } : {}),
...(isHttpUrl ? { readIdleTimeoutMs: REMOTE_MEDIA_READ_IDLE_TIMEOUT_MS } : {}),
ssrfPolicy: remoteMediaSsrfPolicy,
});

View File

@@ -1515,6 +1515,30 @@ describe("createVideoGenerateTool", () => {
expect(call.inputImages?.[1]?.role).toBe("last_frame");
});
it("passes direct remote reference URLs to the provider without local media loading", async () => {
mockVideoPluginProvider({
imageToVideo: { enabled: true, maxInputImages: 1 },
});
const loadWebMedia = vi.spyOn(webMedia, "loadWebMedia").mockResolvedValue({
kind: "image",
buffer: Buffer.from("image"),
contentType: "image/png",
});
const generateSpy = mockSavedVideoResult();
const tool = createVideoPluginTool();
await tool.execute("call-1", {
prompt: "lobster",
image: "https://example.test/reference.png",
});
expect(loadWebMedia).not.toHaveBeenCalled();
const call = firstMockCallArg(generateSpy) as {
inputImages?: Array<{ url?: string }>;
};
expect(call.inputImages).toEqual([{ url: "https://example.test/reference.png" }]);
});
it("passes web_fetch SSRF policy when loading reference assets", async () => {
mockVideoPluginProvider({
imageToVideo: { enabled: true, maxInputImages: 1 },

View File

@@ -6,10 +6,6 @@ import { isRecord } from "../utils.js";
import { asBoolean } from "../utils/boolean.js";
import type { ChannelAccountSnapshot } from "./plugins/types.core.js";
// Read-only status commands project a safe subset of account fields into snapshots
// so renderers can preserve "configured but unavailable" state without touching
// strict runtime-only credential helpers.
const CREDENTIAL_STATUS_KEYS = [
"tokenStatus",
"botTokenStatus",
@@ -33,6 +29,8 @@ function readNullableNumber(
record: Record<string, unknown>,
key: string,
): number | null | undefined {
// Preserve explicit null timestamps; status callers use null to distinguish
// "known empty" from an omitted/unsupported field.
if (record[key] === null) {
return null;
}
@@ -57,6 +55,7 @@ function readCredentialStatus(record: Record<string, unknown>, key: CredentialSt
: undefined;
}
/** Infers configured state from any credential status field on an account snapshot-like object. */
export function resolveConfiguredFromCredentialStatuses(account: unknown): boolean | undefined {
const record = isRecord(account) ? account : null;
if (!record) {
@@ -70,6 +69,8 @@ export function resolveConfiguredFromCredentialStatuses(account: unknown): boole
}
sawCredentialStatus = true;
if (status !== "missing") {
// Any configured credential is enough for coarse account presence; callers
// that require every credential use resolveConfiguredFromRequiredCredentialStatuses.
return true;
}
}
@@ -92,12 +93,15 @@ export function resolveConfiguredFromRequiredCredentialStatuses(
}
sawCredentialStatus = true;
if (status === "missing") {
// Required-credential checks are all-or-nothing so multi-token accounts
// do not appear configured when one mandatory credential is absent.
return false;
}
}
return sawCredentialStatus ? true : undefined;
}
/** Returns true when a credential exists but is unavailable to the current process. */
export function hasConfiguredUnavailableCredentialStatus(account: unknown): boolean {
const record = isRecord(account) ? account : null;
if (!record) {
@@ -108,6 +112,7 @@ export function hasConfiguredUnavailableCredentialStatus(account: unknown): bool
);
}
/** Returns true when an account snapshot exposes an actual credential or available status. */
export function hasResolvedCredentialValue(account: unknown): boolean {
const record = isRecord(account) ? account : null;
if (!record) {
@@ -120,6 +125,7 @@ export function hasResolvedCredentialValue(account: unknown): boolean {
);
}
/** Projects non-secret credential source/status fields into a channel account snapshot. */
export function projectCredentialSnapshotFields(
account: unknown,
): Pick<
@@ -143,6 +149,8 @@ export function projectCredentialSnapshotFields(
const appTokenSource = normalizeOptionalString(record.appTokenSource);
const signingSecretSource = normalizeOptionalString(record.signingSecretSource);
// Only expose source/status metadata. Raw credential fields are intentionally
// omitted here because channel snapshots are safe to display in status output.
return {
...(tokenSource ? { tokenSource } : {}),
...(botTokenSource ? { botTokenSource } : {}),
@@ -166,6 +174,7 @@ export function projectCredentialSnapshotFields(
};
}
/** Projects a safe read-only account snapshot, redacting URL credentials and raw secrets. */
export function projectSafeChannelAccountSnapshotFields(
account: unknown,
): Partial<ChannelAccountSnapshot> {
@@ -232,6 +241,7 @@ export function projectSafeChannelAccountSnapshotFields(
? { allowFrom: readStringArray(record, "allowFrom") }
: {}),
...projectCredentialSnapshotFields(account),
// Status output may display base URLs, but embedded credentials must never leak.
...(baseUrl ? { baseUrl: stripUrlUserInfo(baseUrl) } : {}),
...(readBoolean(record, "allowUnmentionedGroups") !== undefined
? { allowUnmentionedGroups: readBoolean(record, "allowUnmentionedGroups") }

View File

@@ -2,12 +2,14 @@ export type AckReactionScope = "all" | "direct" | "group-all" | "group-mentions"
export type WhatsAppAckReactionMode = "always" | "mentions" | "never";
/** Pending ack reaction plus the provider callback needed to remove it after a reply. */
export type AckReactionHandle = {
ackReactionPromise: Promise<boolean>;
ackReactionValue: string;
remove: () => Promise<void>;
};
/** Channel-neutral facts used to decide whether an inbound message gets an ack reaction. */
export type AckReactionGateParams = {
scope: AckReactionScope | undefined;
isDirect: boolean;
@@ -19,6 +21,7 @@ export type AckReactionGateParams = {
shouldBypassMention?: boolean;
};
/** Apply channel-neutral ack reaction scope rules before a provider sends an emoji. */
export function shouldAckReaction(params: AckReactionGateParams): boolean {
const scope = params.scope ?? "group-mentions";
if (scope === "off" || scope === "none") {
@@ -48,6 +51,7 @@ export function shouldAckReaction(params: AckReactionGateParams): boolean {
return false;
}
/** Adapt WhatsApp's direct/group knobs onto the shared ack reaction gate. */
export function shouldAckReactionForWhatsApp(params: {
emoji: string;
isDirect: boolean;
@@ -84,6 +88,7 @@ export function shouldAckReactionForWhatsApp(params: {
});
}
/** Start sending an ack reaction and retain enough state for optional cleanup. */
export function createAckReactionHandle(params: {
ackReactionValue: string;
send: () => Promise<void>;
@@ -115,6 +120,7 @@ export function createAckReactionHandle(params: {
};
}
/** Remove an ack reaction only after the send path confirmed it was applied. */
export function removeAckReactionAfterReply(params: {
removeAfterReply: boolean;
ackReactionPromise: Promise<boolean> | null;
@@ -139,6 +145,7 @@ export function removeAckReactionAfterReply(params: {
});
}
/** Convenience wrapper for removing a stored ack reaction handle after reply delivery. */
export function removeAckReactionHandleAfterReply(params: {
removeAfterReply: boolean;
ackReaction: AckReactionHandle | null | undefined;

View File

@@ -1,7 +1,9 @@
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
/** Prefix used in allow-from entries that delegate membership to an access group. */
export const ACCESS_GROUP_ALLOW_FROM_PREFIX = "accessGroup:";
/** Parses an access-group allow-from entry and returns the referenced group name. */
export function parseAccessGroupAllowFromEntry(entry: string): string | null {
const trimmed = entry.trim();
if (!trimmed.startsWith(ACCESS_GROUP_ALLOW_FROM_PREFIX)) {
@@ -11,11 +13,14 @@ export function parseAccessGroupAllowFromEntry(entry: string): string | null {
return name.length > 0 ? name : null;
}
/** Merges configured and pairing-store DM allowlists according to the active DM policy. */
export function mergeDmAllowFromSources(params: {
allowFrom?: Array<string | number>;
storeAllowFrom?: Array<string | number>;
dmPolicy?: string;
}): string[] {
// Explicit allowlist/open policy owns the effective list; pairing-store entries only supplement
// pairing/default policies so old approved users do not override a stricter configured list.
const storeEntries =
params.dmPolicy === "allowlist" || params.dmPolicy === "open"
? []
@@ -23,6 +28,7 @@ export function mergeDmAllowFromSources(params: {
return normalizeStringEntries([...(params.allowFrom ?? []), ...storeEntries]);
}
/** Resolves group allow-from entries with optional fallback to the generic allowFrom list. */
export function resolveGroupAllowFromSources(params: {
allowFrom?: Array<string | number>;
groupAllowFrom?: Array<string | number>;
@@ -40,6 +46,7 @@ export function resolveGroupAllowFromSources(params: {
return normalizeStringEntries(scoped);
}
/** Returns the first defined value without treating null/false/empty string as missing. */
export function firstDefined<T>(...values: Array<T | undefined>) {
for (const value of values) {
if (value !== undefined) {
@@ -49,6 +56,7 @@ export function firstDefined<T>(...values: Array<T | undefined>) {
return undefined;
}
/** Checks a normalized sender id against a compiled allowlist summary. */
export function isSenderIdAllowed(
allow: { entries: string[]; hasWildcard: boolean; hasEntries: boolean },
senderId: string | undefined,

View File

@@ -3,6 +3,7 @@ import {
normalizeOptionalLowercaseString,
} from "@openclaw/normalization-core/string-coerce";
/** Candidate class that matched an allowlist entry. */
export type AllowlistMatchSource =
| "wildcard"
| "id"
@@ -15,23 +16,32 @@ export type AllowlistMatchSource =
| "slug"
| "localpart";
/** Allowlist decision plus optional match metadata for diagnostics. */
export type AllowlistMatch<TSource extends string = AllowlistMatchSource> = {
/** Whether the candidate was allowed. */
allowed: boolean;
/** Config entry or wildcard that matched. */
matchKey?: string;
/** Candidate source that matched the config entry. */
matchSource?: TSource;
};
/** Precompiled allowlist for repeated candidate checks. */
export type CompiledAllowlist = {
/** Normalized allowlist entries. */
set: ReadonlySet<string>;
/** Whether the wildcard entry allows every candidate. */
wildcard: boolean;
};
/** Formats match metadata for compact logs and tests. */
export function formatAllowlistMatchMeta(
match?: { matchKey?: string; matchSource?: string } | null,
): string {
return `matchKey=${match?.matchKey ?? "none"} matchSource=${match?.matchSource ?? "none"}`;
}
/** Compiles already-normalized allowlist entries into a lookup set. */
export function compileAllowlist(entries: ReadonlyArray<string>): CompiledAllowlist {
const set = new Set(entries.filter(Boolean));
return {
@@ -48,6 +58,7 @@ function compileSimpleAllowlist(entries: ReadonlyArray<string | number>): Compil
);
}
/** Checks candidates in order, returning the first exact allowlist match. */
export function resolveAllowlistCandidates<TSource extends string>(params: {
compiledAllowlist: CompiledAllowlist;
candidates: Array<{ value?: string; source: TSource }>;
@@ -67,6 +78,7 @@ export function resolveAllowlistCandidates<TSource extends string>(params: {
return { allowed: false };
}
/** Resolves an allowlist decision with wildcard taking precedence over candidate checks. */
export function resolveCompiledAllowlistMatch<TSource extends string>(params: {
compiledAllowlist: CompiledAllowlist;
candidates: Array<{ value?: string; source: TSource }>;
@@ -80,6 +92,7 @@ export function resolveCompiledAllowlistMatch<TSource extends string>(params: {
return resolveAllowlistCandidates(params);
}
/** Compiles an allowlist and resolves it against ordered candidate values. */
export function resolveAllowlistMatchByCandidates<TSource extends string>(params: {
allowList: ReadonlyArray<string>;
candidates: Array<{ value?: string; source: TSource }>;
@@ -90,12 +103,14 @@ export function resolveAllowlistMatchByCandidates<TSource extends string>(params
});
}
/** Resolves the common id/name allowlist shape used by channel sender checks. */
export function resolveAllowlistMatchSimple(params: {
allowFrom: ReadonlyArray<string | number>;
senderId: string;
senderName?: string | null;
allowNameMatching?: boolean;
}): AllowlistMatch<"wildcard" | "id" | "name"> {
// Compile from the current array contents so in-place config edits are visible immediately.
const allowFrom = compileSimpleAllowlist(params.allowFrom);
if (allowFrom.set.size === 0) {
@@ -111,6 +126,7 @@ export function resolveAllowlistMatchSimple(params: {
compiledAllowlist: allowFrom,
candidates: [
{ value: senderId, source: "id" },
// Name matching is opt-in because display names can be mutable or ambiguous.
...(params.allowNameMatching === true && senderName
? ([{ value: senderName, source: "name" as const }] satisfies Array<{
value?: string;

View File

@@ -30,6 +30,7 @@ function dedupeAllowlistEntries(entries: string[]): string[] {
return deduped;
}
/** Appends resolved ids to an allowlist while preserving first-seen casing/order. */
export function mergeAllowlist(params: {
existing?: Array<string | number>;
additions: string[];
@@ -37,6 +38,7 @@ export function mergeAllowlist(params: {
return dedupeAllowlistEntries([...mapAllowFromEntries(params.existing), ...params.additions]);
}
/** Builds resolved/unresolved summaries plus id additions from resolver output. */
export function buildAllowlistResolutionSummary<T extends AllowlistUserResolutionLike>(
resolvedUsers: T[],
opts?: { formatResolved?: (entry: T) => string; formatUnresolved?: (entry: T) => string },
@@ -93,6 +95,7 @@ export function canonicalizeAllowlistWithResolvedIds<
return dedupeAllowlistEntries(canonicalized);
}
/** Rewrites nested `users` arrays in channel config entries after allowlist resolution. */
export function patchAllowlistUsersInConfigEntries<
T extends AllowlistUserResolutionLike,
TEntries extends Record<string, unknown>,
@@ -110,6 +113,7 @@ export function patchAllowlistUsersInConfigEntries<
if (!Array.isArray(users) || users.length === 0) {
continue;
}
// Merge keeps user-facing aliases; canonicalize replaces aliases with stable ids when possible.
const resolvedUsers =
params.strategy === "canonicalize"
? canonicalizeAllowlistWithResolvedIds({
@@ -131,6 +135,7 @@ export function patchAllowlistUsersInConfigEntries<
return nextEntries as TEntries;
}
/** Collects resolvable user aliases from one config entry, excluding wildcard entries. */
export function addAllowlistUserEntriesFromConfigEntry(target: Set<string>, entry: unknown): void {
if (!entry || typeof entry !== "object") {
return;
@@ -147,6 +152,7 @@ export function addAllowlistUserEntriesFromConfigEntry(target: Set<string>, entr
}
}
/** Logs compact allowlist resolution mapping output when there is anything to report. */
export function summarizeMapping(
label: string,
mapping: string[],

View File

@@ -1,19 +1,30 @@
import { normalizeLowercaseStringOrEmpty } from "@openclaw/normalization-core/string-coerce";
import { normalizeUniqueSingleOrTrimmedStringList } from "@openclaw/normalization-core/string-normalization";
/** Source of the config entry selected for a channel target. */
export type ChannelMatchSource = "direct" | "parent" | "wildcard";
/** Match result retaining direct, parent, and wildcard candidates for diagnostics. */
export type ChannelEntryMatch<T> = {
/** Entry selected for the effective config result. */
entry?: T;
/** Config key for the selected entry. */
key?: string;
/** Wildcard fallback entry, retained even when a direct match wins. */
wildcardEntry?: T;
/** Config key for the wildcard fallback entry. */
wildcardKey?: string;
/** Parent conversation entry, retained when direct target matching falls back. */
parentEntry?: T;
/** Config key for the parent conversation entry. */
parentKey?: string;
/** Key that should be reported to callers as the effective match. */
matchKey?: string;
/** Precedence source that produced the effective match. */
matchSource?: ChannelMatchSource;
};
/** Copies match metadata onto a resolved config result. */
export function applyChannelMatchMeta<
TResult extends { matchKey?: string; matchSource?: ChannelMatchSource },
>(result: TResult, match: ChannelEntryMatch<unknown>): TResult {
@@ -24,6 +35,7 @@ export function applyChannelMatchMeta<
return result;
}
/** Resolves the matched entry into a config result while preserving match metadata. */
export function resolveChannelMatchConfig<
TEntry,
TResult extends { matchKey?: string; matchSource?: ChannelMatchSource },
@@ -34,6 +46,7 @@ export function resolveChannelMatchConfig<
return applyChannelMatchMeta(resolveEntry(match.entry), match);
}
/** Normalizes user-visible channel names into lowercase slug keys. */
export function normalizeChannelSlug(value: string): string {
return normalizeLowercaseStringOrEmpty(value)
.replace(/^#/, "")
@@ -41,10 +54,12 @@ export function normalizeChannelSlug(value: string): string {
.replace(/^-+|-+$/g, "");
}
/** Builds deduped key candidates while dropping blank/nullish entries. */
export function buildChannelKeyCandidates(...keys: Array<string | undefined | null>): string[] {
return normalizeUniqueSingleOrTrimmedStringList(keys);
}
/** Finds direct and wildcard entries without applying parent fallback precedence. */
export function resolveChannelEntryMatch<T>(params: {
entries?: Record<string, T>;
keys: string[];
@@ -61,12 +76,15 @@ export function resolveChannelEntryMatch<T>(params: {
break;
}
if (params.wildcardKey && Object.hasOwn(entries, params.wildcardKey)) {
// Keep wildcard metadata even when a direct entry exists so diagnostics can
// explain the fallback that would have applied.
match.wildcardEntry = entries[params.wildcardKey];
match.wildcardKey = params.wildcardKey;
}
return match;
}
/** Resolves channel config by direct match, normalized direct match, parent match, then wildcard. */
export function resolveChannelEntryMatchWithFallback<T>(params: {
entries?: Record<string, T>;
keys: string[];
@@ -86,11 +104,15 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
const normalizeKey = params.normalizeKey;
if (normalizeKey) {
// Normalized direct matching lets display names and ids converge before parent/wildcard
// fallback can broaden the selected config.
const normalizedKeys = params.keys.map((key) => normalizeKey(key)).filter(Boolean);
if (normalizedKeys.length > 0) {
for (const [entryKey, entry] of Object.entries(params.entries ?? {})) {
const normalizedEntry = normalizeKey(entryKey);
if (normalizedEntry && normalizedKeys.includes(normalizedEntry)) {
// Preserve the original configured key as matchKey; callers surface it
// in status/debug output instead of the normalized comparison key.
return {
...direct,
entry,
@@ -118,6 +140,7 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
};
}
if (normalizeKey) {
// Normalized parent keys keep thread/channel parent fallback consistent with direct keys.
const normalizedParentKeys = parentKeys.map((key) => normalizeKey(key)).filter(Boolean);
if (normalizedParentKeys.length > 0) {
for (const [entryKey, entry] of Object.entries(params.entries ?? {})) {
@@ -151,6 +174,7 @@ export function resolveChannelEntryMatchWithFallback<T>(params: {
return direct;
}
/** Resolves nested allowlists where an unconfigured outer/inner list means "no restriction". */
export function resolveNestedAllowlistDecision(params: {
outerConfigured: boolean;
outerMatched: boolean;
@@ -158,6 +182,8 @@ export function resolveNestedAllowlistDecision(params: {
innerMatched: boolean;
}): boolean {
if (!params.outerConfigured) {
// Unconfigured outer lists mean the whole nested policy is inactive; do not
// require an inner match until the outer scope has opted into restriction.
return true;
}
if (!params.outerMatched) {

View File

@@ -1,13 +1,20 @@
export type CommandAuthorizer = {
/** True when this authorizer has policy data for the current sender/context. */
configured: boolean;
/** True when the configured policy allows the control command. */
allowed: boolean;
};
/** Fallback policy used when access groups are disabled for a channel/account. */
export type CommandGatingModeWhenAccessGroupsOff = "allow" | "deny" | "configured";
/** Resolves command authorization from one or more configured policy sources. */
export function resolveCommandAuthorizedFromAuthorizers(params: {
/** True when configured access groups should be enforced. */
useAccessGroups: boolean;
/** Candidate authorizers; any configured allow grants access. */
authorizers: CommandAuthorizer[];
/** Fallback behavior when access groups are disabled. Defaults to allow. */
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
}): boolean {
const { useAccessGroups, authorizers } = params;
@@ -23,16 +30,23 @@ export function resolveCommandAuthorizedFromAuthorizers(params: {
if (!anyConfigured) {
return true;
}
// "configured" preserves legacy permissive behavior until a concrete authorizer exists.
return authorizers.some((entry) => entry.configured && entry.allowed);
}
return authorizers.some((entry) => entry.configured && entry.allowed);
}
/** Returns both command authorization and whether a text control command must be blocked. */
export function resolveControlCommandGate(params: {
/** True when configured access groups should be enforced. */
useAccessGroups: boolean;
/** Candidate authorizers checked before allowing text control commands. */
authorizers: CommandAuthorizer[];
/** True when text commands are enabled for this inbound surface. */
allowTextCommands: boolean;
/** True when the inbound text contains a recognized control command. */
hasControlCommand: boolean;
/** Fallback behavior when access groups are disabled. Defaults to allow. */
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
}): { commandAuthorized: boolean; shouldBlock: boolean } {
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
@@ -44,13 +58,21 @@ export function resolveControlCommandGate(params: {
return { commandAuthorized, shouldBlock };
}
/** Convenience wrapper for text command gates with primary and secondary authorizers. */
export function resolveDualTextControlCommandGate(params: {
/** True when configured access groups should be enforced. */
useAccessGroups: boolean;
/** True when the primary authorizer has policy data for this sender/context. */
primaryConfigured: boolean;
/** True when the primary authorizer allows the command. */
primaryAllowed: boolean;
/** True when the secondary authorizer has policy data for this sender/context. */
secondaryConfigured: boolean;
/** True when the secondary authorizer allows the command. */
secondaryAllowed: boolean;
/** True when the inbound text contains a recognized control command. */
hasControlCommand: boolean;
/** Fallback behavior when access groups are disabled. Defaults to allow. */
modeWhenAccessGroupsOff?: CommandGatingModeWhenAccessGroupsOff;
}): { commandAuthorized: boolean; shouldBlock: boolean } {
return resolveControlCommandGate({

View File

@@ -36,6 +36,7 @@ type ChannelPresenceSignal = {
source: ChannelPresenceSignalSource;
};
/** Returns true when a channel config section has operator data beyond an enabled toggle. */
export function hasMeaningfulChannelConfig(value: unknown): boolean {
if (!isRecord(value)) {
return false;
@@ -43,6 +44,7 @@ export function hasMeaningfulChannelConfig(value: unknown): boolean {
return Object.keys(value).some((key) => key !== "enabled");
}
/** Lists channel ids explicitly disabled in config, normalized for status/activation checks. */
export function listExplicitlyDisabledChannelIdsForConfig(cfg: OpenClawConfig): string[] {
const channels = isRecord(cfg.channels) ? cfg.channels : null;
if (!channels) {
@@ -77,6 +79,7 @@ function listPersistedAuthStateChannelIds(options: ChannelPresenceOptions): read
if (options.discovery) {
return listBundledChannelIdsWithPersistedAuthState(options.discovery);
}
// Bundled persisted-auth metadata is process-stable; cache it outside hot status/plugin lookups.
if (persistedAuthStateChannelIds) {
return persistedAuthStateChannelIds;
}
@@ -102,6 +105,7 @@ function hasPersistedAuthState(params: {
});
}
/** Lists channel ids that appear configured through config, env vars, or persisted auth state. */
export function listPotentialConfiguredChannelIds(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
@@ -114,6 +118,7 @@ export function listPotentialConfiguredChannelIds(
);
}
/** Lists deduped configured-channel signals while preserving their source type. */
export function listPotentialConfiguredChannelPresenceSignals(
cfg: OpenClawConfig,
env: NodeJS.ProcessEnv = process.env,
@@ -192,6 +197,7 @@ function hasEnvConfiguredChannel(
);
}
/** Fast boolean check for any configured channel signal without materializing full plugin state. */
export function hasPotentialConfiguredChannels(
cfg: OpenClawConfig | null | undefined,
env: NodeJS.ProcessEnv = process.env,

View File

@@ -24,6 +24,7 @@ function shouldAppendId(id: string): boolean {
return false;
}
/** Resolves a concise conversation label for session lists, logs, and route summaries. */
export function resolveConversationLabel(ctx: MsgContext): string | undefined {
const explicit = normalizeOptionalString(ctx.ConversationLabel);
if (explicit) {
@@ -69,5 +70,7 @@ export function resolveConversationLabel(ctx: MsgContext): string | undefined {
if (base.startsWith("#") || base.startsWith("@")) {
return base;
}
// Numeric and address-like ids disambiguate generic group labels, but avoid appending them to
// explicit handles/channels or labels that already carry an id.
return `${base} id:${id}`;
}

View File

@@ -33,6 +33,7 @@ type ConversationResolutionSource =
| "inbound-bundled-plugin"
| "inbound-fallback";
/** Canonical conversation identity chosen for binding/spawn decisions. */
type ConversationResolution = {
canonical: {
channel: string;
@@ -45,6 +46,7 @@ type ConversationResolution = {
source: ConversationResolutionSource;
};
/** Raw command context used to resolve the conversation a command should bind to. */
export type ResolveCommandConversationResolutionInput = {
cfg: OpenClawConfig;
channel?: string | null;
@@ -63,6 +65,7 @@ export type ResolveCommandConversationResolutionInput = {
includePlacementHint?: boolean;
};
/** Raw inbound context used to resolve the conversation a message belongs to. */
type ResolveInboundConversationResolutionInput = {
cfg: OpenClawConfig;
channel?: string | null;
@@ -263,6 +266,7 @@ function resolveChannelTargetId(params: {
return target;
}
/** Convert command route facts into the provider hook context without inventing defaults. */
function buildThreadingContext(params: {
fallbackTo?: string;
originatingTo?: string;
@@ -282,6 +286,7 @@ function buildThreadingContext(params: {
};
}
/** Resolve where top-level thread bindings should attach for a channel. */
export function resolveChannelDefaultBindingPlacement(
rawChannel?: string | null,
): "current" | "child" | undefined {
@@ -294,6 +299,7 @@ export function resolveChannelDefaultBindingPlacement(
return pluginPlacement ?? resolveBundledChannelThreadBindingDefaultPlacement(channel);
}
/** Resolve command-originated conversation binding identity, preferring provider hooks first. */
export function resolveCommandConversationResolution(
params: ResolveCommandConversationResolutionInput,
): ConversationResolution | null {
@@ -362,6 +368,7 @@ export function resolveCommandConversationResolution(
return focusedResolution;
}
// Fallback order keeps explicit command/origin targets ahead of ambient context.
const baseConversationId =
resolveChannelTargetId({
channel,
@@ -401,6 +408,7 @@ export function resolveCommandConversationResolution(
});
}
/** Resolve inbound message conversation identity, respecting provider-owned rejection. */
export function resolveInboundConversationResolution(
params: ResolveInboundConversationResolutionInput,
): ConversationResolution | null {
@@ -437,6 +445,7 @@ export function resolveInboundConversationResolution(
plugin,
});
if (providerResolution || providerConversation === null) {
// A null provider result is an explicit rejection; do not reinterpret it generically.
return providerResolution;
}
@@ -453,6 +462,7 @@ export function resolveInboundConversationResolution(
plugin,
});
if (artifactResolution || artifactConversation === null) {
// Bundled artifact resolvers keep the same stop-on-null contract as provider hooks.
return artifactResolution;
}

View File

@@ -14,7 +14,9 @@ import {
import type { ChannelId } from "./plugins/types.public.js";
export type { AccessGroupMembershipResolver } from "../plugin-sdk/access-groups.js";
/** Runtime callbacks needed by the legacy direct-DM authorizer bridge. */
export type DirectDmCommandAuthorizationRuntime = {
/** Returns whether a raw body should run command authorization. */
shouldComputeCommandAuthorized: (rawBody: string, cfg: OpenClawConfig) => boolean;
/** @deprecated Command authorization is resolved by channel ingress. Kept for runtime injection compatibility. */
resolveCommandAuthorizedFromAuthorizers?: (params: {
@@ -26,14 +28,18 @@ export type DirectDmCommandAuthorizationRuntime = {
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
export type ResolvedInboundDirectDmAccess = {
/** DM access decision after configured and pairing-store allowlists are merged. */
access: {
decision: "allow" | "block" | "pairing";
reasonCode: DmGroupAccessReasonCode;
reason: string;
effectiveAllowFrom: string[];
};
/** Whether command authorization was applicable to this inbound body. */
shouldComputeAuth: boolean;
/** Whether the sender matched the effective DM allowlist used for command checks. */
senderAllowedForCommands: boolean;
/** Command authorization result when applicable. */
commandAuthorized: boolean | undefined;
};
@@ -46,11 +52,17 @@ function toLegacyDmReasonCode(reasonCode: string): DmGroupAccessReasonCode {
case DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED:
return reasonCode;
default:
// Legacy direct-DM consumers only understand the compact DM reason enum.
// Unknown ingress reasons fail closed as not-allowlisted.
return DM_GROUP_ACCESS_REASON.DM_POLICY_NOT_ALLOWLISTED;
}
}
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
/**
* Resolves legacy direct-DM access and command authorization for channel adapters.
*
* @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`.
*/
export async function resolveInboundDirectDmAccessWithRuntime(params: {
cfg: OpenClawConfig;
channel: ChannelId;
@@ -79,6 +91,8 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
readStore: params.readStoreAllowFrom,
})
: [];
// Expand configured and pairing-store allowlists independently so diagnostics and command
// authorization use the same effective entries as the legacy DM access decision.
const [allowFrom, effectiveStoreAllowFrom] = await Promise.all([
expandAllowFromWithAccessGroups({
cfg: params.cfg,
@@ -112,6 +126,9 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
params.senderId,
access.effectiveAllowFrom,
);
// Older channel runtimes may not inject the shared command authorizer. Keep
// the local allowlist decision as the fallback so legacy adapters retain their
// pre-access-groups behavior.
const commandAuthorized = shouldComputeAuth
? (params.runtime.resolveCommandAuthorizedFromAuthorizers?.({
useAccessGroups: params.cfg.commands?.useAccessGroups !== false,
@@ -138,7 +155,12 @@ export async function resolveInboundDirectDmAccessWithRuntime(params: {
};
}
/** @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`. */
/**
* Builds the pre-crypto direct-DM authorizer used before encrypted payload
* parsing can hand off to normal channel ingress.
*
* @deprecated Use `resolveChannelMessageIngress` from `openclaw/plugin-sdk/channel-ingress-runtime`.
*/
export function createPreCryptoDirectDmAuthorizer(params: {
resolveAccess: (
senderId: string,
@@ -163,6 +185,8 @@ export function createPreCryptoDirectDmAuthorizer(params: {
return "allow";
}
if (access.decision === "pairing") {
// Pairing challenges are optional because some adapters only need to signal pairing state
// while another layer sends the challenge text.
if (params.issuePairingChallenge) {
await params.issuePairingChallenge({
senderId: input.senderId,
@@ -171,6 +195,8 @@ export function createPreCryptoDirectDmAuthorizer(params: {
}
return "pairing";
}
// Block notifications stay callback-only so pre-crypto adapters can log or
// metric the drop without forcing a reply on hostile or unauthenticated DMs.
params.onBlocked?.({
senderId: input.senderId,
reason: access.reason,

View File

@@ -1,10 +1,15 @@
import { resolveIntegerOption } from "@openclaw/normalization-core/number-coercion";
export type DirectDmPreCryptoGuardPolicy = {
/** Provider message kinds accepted before decrypted content is available. */
allowedKinds: readonly number[];
/** Maximum future timestamp skew accepted before rejecting a message. */
maxFutureSkewSec: number;
/** Maximum encrypted payload bytes accepted before crypto work starts. */
maxCiphertextBytes: number;
/** Maximum decrypted plaintext bytes accepted after crypto succeeds. */
maxPlaintextBytes: number;
/** Per-sender and global limits applied before expensive crypto/decode work. */
rateLimit: {
windowMs: number;
maxPerSenderPerWindow: number;

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