Compare commits

..

512 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
656 changed files with 8298 additions and 3924 deletions

View File

@@ -605,19 +605,7 @@ jobs:
restore-keys: |
${{ runner.os }}-build-all-v3-
- name: Restore dist build cache
id: dist_build_cache
uses: actions/cache/restore@v5
with:
path: |
dist/
dist-runtime/
extensions/*/src/host/**/.bundle.hash
extensions/*/src/host/**/*.bundle.js
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
- name: Build dist
if: steps.dist_build_cache.outputs.cache-hit != 'true'
env:
NODE_OPTIONS: --max-old-space-size=8192
run: pnpm build:ci-artifacts
@@ -626,6 +614,14 @@ jobs:
if: needs.preflight.outputs.run_control_ui_i18n == 'true'
run: pnpm ui:i18n:check
- name: Cache dist build
uses: actions/cache@v5
with:
path: |
dist/
dist-runtime/
key: ${{ runner.os }}-dist-build-${{ needs.preflight.outputs.checkout_revision }}
- name: Pack built runtime artifacts
run: tar --posix -cf dist-runtime-build.tar.zst --use-compress-program zstdmt dist dist-runtime
@@ -755,18 +751,6 @@ jobs:
done
exit "$failures"
- name: Save dist build cache
if: steps.dist_build_cache.outputs.cache-hit != 'true'
uses: actions/cache/save@v5
continue-on-error: true
with:
path: |
dist/
dist-runtime/
extensions/*/src/host/**/.bundle.hash
extensions/*/src/host/**/*.bundle.js
key: ${{ steps.dist_build_cache.outputs.cache-primary-key }}
- name: Upload gateway watch regression artifacts
if: always() && needs.preflight.outputs.run_check_additional == 'true'
uses: actions/upload-artifact@v7

View File

@@ -657,7 +657,6 @@ Docs: https://docs.openclaw.ai
- Gateway/sessions: allow shared-secret bearer callers to read and stream session history without an explicit scope header. (#81815) Thanks @medns.
- Agents/embedded runner: classify HTML auth provider responses as `auth_html` and return a re-authentication hint instead of the CDN-blocked copy that `upstream_html` returns. Cloudflare Access login pages, nginx basic-auth challenges, and gateway login walls all produce HTML auth bodies that were previously misdiagnosed as transient CDN blocks. (#79900) Thanks @martingarramon.
- TUI/streaming watchdog: dismiss the `This response is taking longer than expected` notice as soon as a chat event for the same run arrives, so the message no longer sits next to the recovered response when the run was only briefly silent. Refs #67052, #69081 (closed), prior attempt #69026. Thanks @jpruit20 and @romneyda.
- Agents/auth profiles: replace the bare `No available auth profile for <provider> (all in cooldown or unavailable)` TUI error with plain-language copy that explains what happened in user terms (sign-in expired, provider asking us to slow down, billing issue on the account, etc.) and suggests the matching `openclaw models auth login --provider <provider>` recovery command for sign-in and billing causes, while falling back to the underlying provider error for cases without a clear recovery path. Thanks @romneyda.
- Agents/Pi: tolerate OpenClaw-owned transcript writes while embedded prompts are released for model I/O, keeping long-running Feishu, Slack, Telegram, and cron turns from failing with false session-takeover errors. Fixes #84059. (#84250) Thanks @tianxiaochannel-oss88.
## 2026.5.20

View File

@@ -85,10 +85,6 @@ OpenClaw indexes `MEMORY.md` and `memory/*.md` into chunks (~400 tokens with
- **Storage maintenance:** SQLite WAL sidecars are bounded with periodic and
shutdown checkpoints.
- **File watching:** changes to memory files trigger a debounced reindex (1.5s).
File watching is enabled by default, including for gateways, so memory edits
become searchable without a manual reindex. Large memory trees, `extraPaths`,
or QMD collections can use many file descriptors in long-lived gateways; set
`sync.watch: false` for affected agents if that becomes a problem.
- **Auto-reindex:** when the embedding provider, model, or chunking config
changes, the entire index is rebuilt automatically.
- **Reindex on demand:** `openclaw memory index --force`
@@ -129,8 +125,8 @@ openclaw memory index --force --agent main
Both standalone CLI commands and the Gateway use the same `local` provider id.
Set `memorySearch.provider: "local"` when you want local embeddings.
**Stale results?** Run `openclaw memory index --force` to rebuild. Use this when
file watching is disabled or misses a change.
**Stale results?** Run `openclaw memory index --force` to rebuild. The watcher
may miss changes in rare edge cases.
**sqlite-vec not loading?** OpenClaw falls back to in-process cosine similarity
automatically. `openclaw memory status --deep` reports the local vector store

View File

@@ -527,9 +527,7 @@ QMD model overrides stay on the QMD side, not OpenClaw config. If you need to ov
</Accordion>
</AccordionGroup>
QMD boot refreshes use a one-shot subprocess path during gateway startup. The long-lived QMD manager owns the regular file watcher and interval timers when memory search is opened for interactive use.
Local Gateway configs can warn when memory file watching may keep too many files open. If you see open-file or watcher errors, set `sync.watch: false` for the affected agents and use manual indexing or `sync.intervalMinutes` to refresh memory.
QMD boot refreshes use a one-shot subprocess path during gateway startup. The long-lived QMD manager still owns the regular file watcher and interval timers when memory search is opened for interactive use.
### Full QMD example

View File

@@ -266,7 +266,6 @@ export async function startCodexAttemptThread(params: {
mcpServersFingerprintEvaluated: params.bundleMcpThreadConfig.evaluated,
environmentSelection: startupEnvironmentSelection,
contextEngineProjection: params.contextEngineProjection,
signal: params.signal,
pluginThreadConfig: pluginThreadConfigRequired
? {
enabled: true,

View File

@@ -169,42 +169,6 @@ function createTwoCalendarAppPolicyContext() {
setupRunAttemptTestHooks();
describe("Codex app-server thread lifecycle bindings", () => {
it("does not write a binding when thread start resolves after abort", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");
const params = createParams(sessionFile, workspaceDir);
const appServer = createThreadLifecycleAppServerOptions();
const abortController = new AbortController();
let resolveStart: ((value: ReturnType<typeof threadStartResult>) => void) | undefined;
const request = vi.fn(async (method: string) => {
if (method === "thread/start") {
return await new Promise<ReturnType<typeof threadStartResult>>((resolve) => {
resolveStart = resolve;
});
}
throw new Error(`unexpected method: ${method}`);
});
const run = startOrResumeThread({
client: { request } as never,
params,
cwd: workspaceDir,
dynamicTools: [],
appServer,
signal: abortController.signal,
});
await vi.waitFor(() =>
expect(request).toHaveBeenCalledWith("thread/start", expect.any(Object), {
signal: abortController.signal,
}),
);
abortController.abort("test_abort");
resolveStart?.(threadStartResult("thread-after-abort"));
await expect(run).rejects.toThrow("test_abort");
await expect(readCodexAppServerBinding(sessionFile)).resolves.toBeUndefined();
});
it("resumes a bound Codex thread when only dynamic tool descriptions change", async () => {
const sessionFile = path.join(tempDir, "session.jsonl");
const workspaceDir = path.join(tempDir, "workspace");

View File

@@ -243,7 +243,6 @@ export async function startOrResumeThread(params: {
environmentSelection?: CodexTurnEnvironmentParams[];
pluginThreadConfig?: CodexPluginThreadConfigProvider;
contextEngineProjection?: CodexContextEngineThreadBootstrapProjection;
signal?: AbortSignal;
}): Promise<CodexAppServerThreadLifecycleBinding> {
// Thread lifecycle spans are useful when profiling startup churn, but normal
// turns should not pay Date.now/span-array overhead while resuming threads.
@@ -276,22 +275,6 @@ export async function startOrResumeThread(params: {
let preserveExistingBinding = false;
let rotatedContextEngineBinding = false;
let prebuiltPluginThreadConfig: CodexPluginThreadConfig | undefined;
const throwIfAborted = () => {
if (!params.signal?.aborted) {
return;
}
const reason = params.signal.reason;
if (reason instanceof Error) {
throw reason;
}
const error = new Error(
typeof reason === "string" && reason.length > 0
? reason
: "codex app-server thread lifecycle aborted",
);
error.name = "AbortError";
throw error;
};
if (binding?.threadId && params.nativeCodeModeEnabled === false) {
embeddedAgentLog.debug(
"codex app-server native tool surface disabled for turn; starting transient thread",
@@ -463,10 +446,9 @@ export async function startOrResumeThread(params: {
);
const response = assertCodexThreadResumeResponse(
await lifecycleTiming.measure("thread_resume_request", () =>
params.client.request("thread/resume", resumeParams, { signal: params.signal }),
params.client.request("thread/resume", resumeParams),
),
);
throwIfAborted();
const boundAuthProfileId = authProfileId;
const fallbackModelProvider = resolveCodexAppServerModelProvider({
provider: params.params.provider,
@@ -588,7 +570,7 @@ export async function startOrResumeThread(params: {
);
const threadStartResponse = await lifecycleTiming.measure("thread_start_request", async () => {
try {
return await params.client.request("thread/start", startParams, { signal: params.signal });
return await params.client.request("thread/start", startParams);
} catch (error) {
if (error instanceof CodexAppServerRpcError) {
throw new CodexThreadStartRequestError(error);
@@ -597,7 +579,6 @@ export async function startOrResumeThread(params: {
}
});
const response = assertCodexThreadStartResponse(threadStartResponse);
throwIfAborted();
const modelProvider = resolveCodexAppServerModelProvider({
provider: params.params.provider,
authProfileId: params.params.authProfileId,

View File

@@ -569,41 +569,6 @@ describe("QmdMemoryManager", () => {
await manager.close();
});
it("logs qmd watcher errors without throwing", async () => {
cfg = {
agents: {
defaults: {
workspace: workspaceDir,
memorySearch: {
provider: "openai",
model: "mock-embed",
store: { path: path.join(workspaceDir, "index.sqlite"), vector: { enabled: false } },
sync: { watch: true, watchDebounceMs: 25, onSessionStart: false, onSearch: false },
},
},
list: [{ id: agentId, default: true, workspace: workspaceDir }],
},
memory: {
backend: "qmd",
qmd: {
includeDefaultMemory: false,
update: { interval: "0s", debounceMs: 0, onBoot: false },
paths: [{ path: workspaceDir, pattern: "**/*.md", name: "workspace" }],
},
},
} as OpenClawConfig;
const { manager } = await createManager({ mode: "full" });
const watcher = watchMock.mock.results[0]?.value as {
emit: (event: string, ...args: unknown[]) => boolean;
};
expect(watcher.emit("error", new Error("watcher error: ENOSPC"))).toBe(true);
expect(logWarnMock).toHaveBeenCalledWith("qmd watcher error: watcher error: ENOSPC");
await manager.close();
});
it("delays qmd watch sync until changed file stats settle", async () => {
vi.useFakeTimers();
cfg = {

View File

@@ -1616,10 +1616,6 @@ export class QmdMemoryManager implements MemorySearchManager {
this.watcher.on("add", markDirty);
this.watcher.on("change", markDirty);
this.watcher.on("unlink", markDirty);
this.watcher.on("error", (err) => {
const message = err instanceof Error ? err.message : String(err);
log.warn(`qmd watcher error: ${message}`);
});
this.watcher.once("ready", () => {
log.info(
`qmd watcher ready for agent "${this.agentId}" paths=${watchPathList.length} durationMs=${Date.now() - startTime}`,

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

@@ -13,7 +13,7 @@ import {
statSync,
writeFileSync,
} from "node:fs";
import { homedir, tmpdir } from "node:os";
import { tmpdir } from "node:os";
import { delimiter, dirname, extname, isAbsolute, relative, resolve } from "node:path";
import { fileURLToPath } from "node:url";
import { resolvePathEnvKey } from "./windows-cmd-helpers.mjs";
@@ -1540,9 +1540,7 @@ function isWindowsRemoteTarget(commandArgs) {
}
function isNativeWindowsRemoteTarget(commandArgs) {
return (
isWindowsRemoteTarget(commandArgs) && optionValue(commandArgs, "--windows-mode") !== "wsl2"
);
return isWindowsRemoteTarget(commandArgs) && optionValue(commandArgs, "--windows-mode") !== "wsl2";
}
function isAwsMacosRemoteTarget(commandArgs, providerName) {
@@ -1555,14 +1553,14 @@ function isAwsMacosRemoteTarget(commandArgs, providerName) {
function remoteWindowsHydratedNodeModulesBootstrap() {
return [
"$openclawModulesDir = $env:PNPM_CONFIG_MODULES_DIR",
"if ($openclawModulesDir) {",
'$openclawModulesDir = $env:PNPM_CONFIG_MODULES_DIR',
'if ($openclawModulesDir) {',
'if (-not (Test-Path $openclawModulesDir)) { throw "PNPM_CONFIG_MODULES_DIR does not exist: $openclawModulesDir" }',
'$openclawWorkspaceModules = Join-Path (Get-Location).Path "node_modules"',
'$openclawSelfModules = Join-Path $openclawModulesDir "node_modules"',
'if (-not (Test-Path $openclawSelfModules)) { cmd /c mklink /J "$openclawSelfModules" "$openclawModulesDir" | Out-Host; if ($LASTEXITCODE -ne 0) { throw "failed to link hydrated pnpm node_modules" } }',
'if (-not (Test-Path $openclawWorkspaceModules)) { cmd /c mklink /J "$openclawWorkspaceModules" "$openclawModulesDir" | Out-Host; if ($LASTEXITCODE -ne 0) { throw "failed to link workspace node_modules" } }',
"}",
'}',
].join("; ");
}
@@ -1896,23 +1894,8 @@ function shouldUseFullCheckoutForCleanRemoteSync(commandArgs, _providerName) {
return isSparseCheckout() || isChangedGateCommand(runCommandArgs(commandArgs));
}
function defaultFullCheckoutSyncRoot() {
const home = homedir();
if (home) {
return resolve(home, ".cache", "openclaw", "crabbox-sync");
}
return resolve(tmpdir(), "openclaw-crabbox-sync");
}
function fullCheckoutSyncRoot() {
const configured = process.env.OPENCLAW_CRABBOX_SYNC_TMPDIR?.trim();
const root = configured ? resolve(configured) : defaultFullCheckoutSyncRoot();
mkdirSync(root, { recursive: true });
return root;
}
function prepareFullCheckoutForSync(options = {}) {
const dir = mkdtempSync(resolve(fullCheckoutSyncRoot(), "openclaw-crabbox-sync-"));
const dir = mkdtempSync(resolve(tmpdir(), "openclaw-crabbox-sync-"));
let active = false;
const add = gitOutput(["worktree", "add", "--detach", dir, "HEAD"]);
if (add.status !== 0) {

View File

@@ -2,7 +2,6 @@ import { spawn } from "node:child_process";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import path from "node:path";
import { pathToFileURL } from "node:url";
type Options = {
altScreen: boolean;
@@ -21,24 +20,6 @@ const MODE_TEST_FILES = {
const MIRROR_TERMINAL_QUERIES = ["\x1b[?u", "\x1b[16t"];
const DEFAULT_PTY_COLS = 100;
const DEFAULT_PTY_ROWS = 30;
const CHILD_SIGTERM_GRACE_MS = 500;
const CHILD_SIGKILL_GRACE_MS = 5_000;
type KillableChild = {
pid?: number;
kill(signal: NodeJS.Signals): boolean;
};
type ChildStopper = {
cancel: () => void;
stop: () => void;
};
type SignalChild = (child: KillableChild, signal: NodeJS.Signals) => void;
function unrefTimer(timer: ReturnType<typeof setTimeout>): void {
(timer as { unref?: () => void }).unref?.();
}
function readOption(args: string[], name: string): string | undefined {
const idx = args.indexOf(name);
@@ -91,64 +72,6 @@ function currentTerminalDimension(value: number | undefined, fallback: number):
return String(value && value > 0 ? value : fallback);
}
function signalChildProcessTree(child: KillableChild, signal: NodeJS.Signals): void {
if (process.platform !== "win32" && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Non-detached fallback or already-exited group; direct child signaling is
// still useful on platforms without process groups.
}
}
child.kill(signal);
}
function createChildStopper(
child: KillableChild,
options: {
signalChild?: SignalChild;
sigtermGraceMs?: number;
sigkillGraceMs?: number;
} = {},
): ChildStopper {
const signalChild = options.signalChild ?? signalChildProcessTree;
const sigtermGraceMs = options.sigtermGraceMs ?? CHILD_SIGTERM_GRACE_MS;
const sigkillGraceMs = options.sigkillGraceMs ?? CHILD_SIGKILL_GRACE_MS;
let stopping = false;
let termTimer: ReturnType<typeof setTimeout> | undefined;
let killTimer: ReturnType<typeof setTimeout> | undefined;
const cancel = () => {
if (termTimer) {
clearTimeout(termTimer);
termTimer = undefined;
}
if (killTimer) {
clearTimeout(killTimer);
killTimer = undefined;
}
};
const stop = () => {
if (stopping) {
return;
}
stopping = true;
signalChild(child, "SIGINT");
termTimer = setTimeout(() => {
signalChild(child, "SIGTERM");
killTimer = setTimeout(() => {
signalChild(child, "SIGKILL");
}, sigkillGraceMs);
unrefTimer(killTimer);
}, sigtermGraceMs);
unrefTimer(termTimer);
};
return { cancel, stop };
}
async function createMirrorFile(mirrorPath: string): Promise<void> {
await mkdir(path.dirname(mirrorPath), { recursive: true });
await writeFile(mirrorPath, "", "utf8");
@@ -185,7 +108,6 @@ async function main(): Promise<void> {
],
{
cwd: process.cwd(),
detached: process.platform !== "win32",
env: {
...process.env,
OPENCLAW_TUI_PTY_MIRROR_PATH: options.mirrorPath,
@@ -250,8 +172,10 @@ async function main(): Promise<void> {
}
};
const childStopper = createChildStopper(child);
const stopChild = childStopper.stop;
const stopChild = () => {
child.kill("SIGINT");
setTimeout(() => child.kill("SIGTERM"), 500).unref();
};
const ignoredInput = (chunk: Buffer) => {
if (chunk.includes(0x03)) {
@@ -314,20 +238,12 @@ async function main(): Promise<void> {
childStderr += chunk.toString("utf8");
});
type ChildExit = { code: number | null; signal: NodeJS.Signals | null };
let childExit: ChildExit | null = null;
const childFinished = new Promise<ChildExit>((resolve) => {
child.once("exit", (code, signal) => {
childExit = { code, signal };
childStopper.cancel();
resolve(childExit);
});
let childExit: { code: number | null; signal: NodeJS.Signals | null } | null = null;
child.on("exit", (code, signal) => {
childExit = { code, signal };
});
const parentSignals: NodeJS.Signals[] = ["SIGINT", "SIGTERM", "SIGHUP"];
for (const signal of parentSignals) {
process.once(signal, stopChild);
}
process.once("SIGINT", stopChild);
try {
for (;;) {
@@ -349,12 +265,6 @@ async function main(): Promise<void> {
writeMirrorChunk(result.chunk);
}
} finally {
if (!childExit) {
stopChild();
}
for (const signal of parentSignals) {
process.off(signal, stopChild);
}
await drainParentInput();
restoreInput();
if (useAltScreen) {
@@ -363,10 +273,6 @@ async function main(): Promise<void> {
restoreScreen();
}
if (!childExit) {
childExit = await childFinished;
}
if (childStdout) {
process.stdout.write(childStdout);
}
@@ -382,16 +288,9 @@ async function main(): Promise<void> {
}
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
main().catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.stack || error.message : String(error)}\n`,
);
process.exit(1);
});
}
export const testing = {
createChildStopper,
signalChildProcessTree,
};
main().catch((error: unknown) => {
process.stderr.write(
`${error instanceof Error ? error.stack || error.message : String(error)}\n`,
);
process.exit(1);
});

View File

@@ -16,7 +16,6 @@ for env_name in \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_TOTAL \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_INDEX \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_IDS \
OPENCLAW_BUNDLED_PLUGIN_SWEEP_COMMAND_TIMEOUT \
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_SMOKE \
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_PORT_BASE \
OPENCLAW_BUNDLED_PLUGIN_RUNTIME_OUTPUT_CHARS \

View File

@@ -4,36 +4,15 @@ import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { setTimeout as delay } from "node:timers/promises";
import { pathToFileURL } from "node:url";
import { promisify } from "node:util";
import type { GatewayRpcClient } from "./mcp-channels-harness.ts";
import { assert, connectGateway, type GatewayRpcClient, waitFor } from "./mcp-channels-harness.ts";
const execFileAsync = promisify(execFile);
const PROBE_PID_WAIT_MS = readPositiveInt(
process.env.OPENCLAW_CRON_MCP_CLEANUP_PID_WAIT_MS,
120_000,
);
type McpChannelsHarness = typeof import("./mcp-channels-harness.ts");
let mcpChannelsHarness: McpChannelsHarness | undefined;
type CronJob = { id?: string };
type CronRunResult = { ok?: boolean; enqueued?: boolean; runId?: string };
type AgentRunResult = { runId?: string; status?: string };
async function loadMcpChannelsHarness(): Promise<McpChannelsHarness> {
mcpChannelsHarness ??= await import("./mcp-channels-harness.ts");
return mcpChannelsHarness;
}
function readPositiveInt(raw: string | undefined, fallback: number): number {
const text = (raw ?? "").trim();
if (!/^\d+$/u.test(text)) {
return fallback;
}
const parsed = Number(text);
return Number.isInteger(parsed) && parsed > 0 ? parsed : fallback;
}
async function readProbePid(pidPath: string): Promise<number | undefined> {
try {
const raw = (await fs.readFile(pidPath, "utf-8")).trim();
@@ -73,19 +52,14 @@ async function describeProbePid(pid: number): Promise<string | undefined> {
}
}
export async function waitForProbePid(
pidPath: string,
options: { pollMs?: number; timeoutMs?: number } = {},
): Promise<number | undefined> {
const timeoutMs = options.timeoutMs ?? PROBE_PID_WAIT_MS;
const pollMs = options.pollMs ?? 100;
async function waitForProbePid(pidPath: string): Promise<number | undefined> {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
while (Date.now() - startedAt < 600_000) {
const pid = await readProbePid(pidPath);
if (pid) {
return pid;
}
await delay(pollMs);
await delay(100);
}
return undefined;
}
@@ -154,7 +128,6 @@ async function runCronCleanupScenario(params: {
gateway: GatewayRpcClient;
pidPath: string;
}): Promise<{ jobId: string; runId?: string; pid: number; status?: unknown }> {
const { assert, waitFor } = await loadMcpChannelsHarness();
const { gateway, pidPath } = params;
const job = await gateway.request<CronJob>("cron.add", {
name: "cron mcp cleanup docker e2e",
@@ -198,7 +171,7 @@ async function runCronCleanupScenario(params: {
const pid = await waitForProbePid(pidPath);
assert(
pid,
`cron MCP probe did not start within ${PROBE_PID_WAIT_MS}ms; missing pid file at ${pidPath}; events=${JSON.stringify(
`cron MCP probe did not start; missing pid file at ${pidPath}; events=${JSON.stringify(
gateway.events.slice(-10),
)}`,
);
@@ -236,7 +209,6 @@ async function runSubagentCleanupScenario(params: {
pidsPath: string;
exitPath: string;
}): Promise<{ runId: string; exitedPids: number[]; pids: number[] }> {
const { assert } = await loadMcpChannelsHarness();
const { gateway, pidPath, pidsPath, exitPath } = params;
await resetProbeFiles({ pidPath, pidsPath, exitPath });
@@ -286,7 +258,6 @@ async function runSubagentCleanupScenario(params: {
}
async function main() {
const { assert, connectGateway } = await loadMcpChannelsHarness();
const gatewayUrl = process.env.GW_URL?.trim();
const gatewayToken = process.env.GW_TOKEN?.trim();
const stateDir = process.env.OPENCLAW_STATE_DIR?.trim() || path.join(os.homedir(), ".openclaw");
@@ -312,6 +283,4 @@ async function main() {
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}
await main();

View File

@@ -56,10 +56,9 @@ const FORBIDDEN_POST_READY_DEPS_WORK = [/\b(?:npm|pnpm|yarn|corepack) install\b/
const PACKAGE_MANAGER_PROCESS_BASENAME = /^(?:npm|pnpm|yarn|corepack)(?:$|[.-])/u;
const PROCESS_SNAPSHOT_ARGS = ["-ww", "-eo", "pid=,ppid=,args="];
const isolatedStateRoots = new WeakMap();
const activeCommandChildren = new Set();
const activeGatewayChildren = new Set();
const parentSignalHandlers = new Map();
let parentCleanupInstalled = false;
let gatewayExitCleanupInstalled = false;
function readPositiveInt(raw, fallback) {
const text = String(raw ?? "").trim();
@@ -401,15 +400,10 @@ function createBoundedGatewayLog(logPath) {
export function runCommand(command, args, options = {}) {
return new Promise((resolve, reject) => {
const { timeoutMs = COMMAND_TIMEOUT_MS, ...spawnOptions } = options;
const detached = spawnOptions.detached ?? process.platform !== "win32";
const child = childProcess.spawn(command, args, {
stdio: ["ignore", "pipe", "pipe"],
...spawnOptions,
detached,
});
if (detached) {
trackCommandChild(child);
}
let stdout = { text: "", truncatedChars: 0 };
let stderr = { text: "", truncatedChars: 0 };
let timedOut = false;
@@ -423,7 +417,7 @@ export function runCommand(command, args, options = {}) {
const clearCommandTimer = timeoutMs
? setTimeout(() => {
timedOut = true;
signalChildProcessTree(child, "SIGKILL");
child.kill("SIGKILL");
}, timeoutMs)
: undefined;
child.on("error", (error) => {
@@ -514,24 +508,14 @@ function trackGatewayChild(child) {
};
child.once("error", untrack);
child.once("close", untrack);
installParentCleanup();
installGatewayParentCleanup();
}
function trackCommandChild(child) {
activeCommandChildren.add(child);
const untrack = () => {
activeCommandChildren.delete(child);
};
child.once("error", untrack);
child.once("close", untrack);
installParentCleanup();
}
function installParentCleanup() {
if (!parentCleanupInstalled) {
parentCleanupInstalled = true;
function installGatewayParentCleanup() {
if (!gatewayExitCleanupInstalled) {
gatewayExitCleanupInstalled = true;
process.once("exit", () => {
cleanupActiveChildren("SIGTERM");
cleanupActiveGatewayChildren("SIGTERM");
});
}
for (const signal of ["SIGHUP", "SIGINT", "SIGTERM"]) {
@@ -539,7 +523,7 @@ function installParentCleanup() {
continue;
}
const handler = () => {
cleanupActiveChildren(signal);
cleanupActiveGatewayChildren(signal);
for (const [registeredSignal, registeredHandler] of parentSignalHandlers) {
process.off(registeredSignal, registeredHandler);
}
@@ -551,17 +535,11 @@ function installParentCleanup() {
}
}
function cleanupActiveChildren(signal) {
for (const child of activeCommandChildren) {
signalChildProcessTree(child, signal);
if (process.platform !== "win32") {
signalChildProcessTree(child, "SIGKILL");
}
}
function cleanupActiveGatewayChildren(signal) {
for (const child of activeGatewayChildren) {
signalChildProcessTree(child, signal);
signalGateway(child, signal);
if (process.platform !== "win32") {
signalChildProcessTree(child, "SIGKILL");
signalGateway(child, "SIGKILL");
}
}
}
@@ -581,11 +559,11 @@ export async function stopGateway(child) {
return !processTreeIsAlive(child);
};
signalChildProcessTree(child, "SIGTERM");
signalGateway(child, "SIGTERM");
if (await waitForExit(GATEWAY_TEARDOWN_GRACE_MS)) {
return;
}
signalChildProcessTree(child, "SIGKILL");
signalGateway(child, "SIGKILL");
await waitForExit(GATEWAY_TEARDOWN_KILL_GRACE_MS);
}
@@ -607,14 +585,15 @@ function processTreeIsAlive(child) {
}
}
function signalChildProcessTree(child, signal) {
function signalGateway(child, signal) {
if (process.platform !== "win32" && typeof child.pid === "number") {
try {
process.kill(-child.pid, signal);
return;
} catch {
// Non-detached callers may not own a process group keyed by child.pid; keep
// the legacy direct-child kill path as the fallback.
} catch (error) {
if (error?.code === "ESRCH") {
return;
}
}
}
try {

View File

@@ -19,30 +19,11 @@ openclaw_e2e_eval_test_state_from_b64 "${OPENCLAW_TEST_STATE_SCRIPT_B64:?missing
probe="scripts/e2e/lib/bundled-plugin-install-uninstall/probe.mjs"
runtime_smoke="scripts/e2e/lib/bundled-plugin-install-uninstall/runtime-smoke.mjs"
node "$probe" select > /tmp/bundled-plugin-sweep-ids
sweep_command_timeout="${OPENCLAW_BUNDLED_PLUGIN_SWEEP_COMMAND_TIMEOUT:-300s}"
now_ms() {
node -e 'process.stdout.write(String(Date.now()))'
}
run_logged_sweep_command() {
local label="$1"
local log_file="$2"
shift 2
if openclaw_e2e_maybe_timeout "$sweep_command_timeout" "$@" >"$log_file" 2>&1; then
return 0
else
local status=$?
cat "$log_file"
if [ "$status" -eq 124 ]; then
echo "Bundled plugin sweep command timed out after $sweep_command_timeout: $label" >&2
else
echo "Bundled plugin sweep command failed with status $status: $label" >&2
fi
return "$status"
fi
}
lifecycle_trace_enabled() {
case "${OPENCLAW_PLUGIN_LIFECYCLE_TRACE:-}" in
1 | true | TRUE | yes | YES)
@@ -72,8 +53,10 @@ for plugin_entry in "${plugin_entries[@]}"; do
uninstall_log="/tmp/openclaw-uninstall-${plugin_index}.log"
plugin_started_at="$(now_ms)"
echo "Installing bundled plugin: $plugin_id ($plugin_dir)"
run_logged_sweep_command "install $plugin_id" "$install_log" \
node "$OPENCLAW_ENTRY" plugins install "$plugin_id"
node "$OPENCLAW_ENTRY" plugins install "$plugin_id" >"$install_log" 2>&1 || {
cat "$install_log"
exit 1
}
if lifecycle_trace_enabled; then
cat "$install_log"
fi
@@ -91,8 +74,10 @@ for plugin_entry in "${plugin_entries[@]}"; do
runtime_finished_at="$(now_ms)"
echo "Uninstalling bundled plugin: $plugin_id ($plugin_dir)"
run_logged_sweep_command "uninstall $plugin_id" "$uninstall_log" \
node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force
node "$OPENCLAW_ENTRY" plugins uninstall "$plugin_id" --force >"$uninstall_log" 2>&1 || {
cat "$uninstall_log"
exit 1
}
if lifecycle_trace_enabled; then
cat "$uninstall_log"
fi

View File

@@ -14,22 +14,22 @@ async function loadCallGateway() {
throw new Error(`unable to find callGateway export in /app/dist (${candidates.join(", ")})`);
}
const DEFAULT_RAW_SCHEMA_ERROR =
"400 The following tools cannot be used with reasoning.effort 'minimal': web_search.";
const callGateway = await loadCallGateway();
function readExpectedRawSchemaError() {
return process.env.RAW_SCHEMA_ERROR?.trim() || DEFAULT_RAW_SCHEMA_ERROR;
const port = process.env.PORT;
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
const mode = process.argv[2];
const sessionKey = `agent:main:openai-web-search-minimal:${mode}`;
const message =
mode === "reject" ? "FORCE_SCHEMA_REJECT" : "Return exactly OPENCLAW_SCHEMA_E2E_OK.";
const id = mode === "reject" ? "schema-reject" : "schema-success";
if (!port || !token) {
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
}
async function gatewayAgent(params) {
const port = process.env.PORT;
const token = process.env.OPENCLAW_GATEWAY_TOKEN;
if (!port || !token) {
throw new Error("missing PORT/OPENCLAW_GATEWAY_TOKEN");
}
try {
const callGateway = await loadCallGateway();
return {
ok: true,
value: await callGateway({
@@ -51,51 +51,24 @@ async function gatewayAgent(params) {
}
}
function stringifyError(value) {
return value instanceof Error ? value.message || String(value) : String(value);
const result = await gatewayAgent({
sessionKey,
message,
thinking: "minimal",
deliver: false,
timeout: 180,
idempotencyKey: id,
});
if (mode === "reject") {
console.error(result.ok ? JSON.stringify(result.value) : String(result.error));
process.exit(0);
}
function validateRejectResult(result, expectedRawSchemaError = readExpectedRawSchemaError()) {
if (result.ok) {
throw new Error(`reject mode unexpectedly completed: ${JSON.stringify(result.value)}`);
}
const errorText = stringifyError(result.error);
if (!errorText.includes(expectedRawSchemaError)) {
throw new Error(
`reject mode failed for an unexpected reason; expected ${JSON.stringify(
expectedRawSchemaError,
)} in ${JSON.stringify(errorText)}`,
);
}
return errorText;
if (!result.ok) {
throw toLintErrorObject(result.error, "Non-Error thrown");
}
async function main() {
const mode = process.argv[2];
const sessionKey = `agent:main:openai-web-search-minimal:${mode}`;
const message =
mode === "reject" ? "FORCE_SCHEMA_REJECT" : "Return exactly OPENCLAW_SCHEMA_E2E_OK.";
const id = mode === "reject" ? "schema-reject" : "schema-success";
const result = await gatewayAgent({
sessionKey,
message,
thinking: "minimal",
deliver: false,
timeout: 180,
idempotencyKey: id,
});
if (mode === "reject") {
console.error(validateRejectResult(result));
return;
}
if (!result.ok) {
throw toLintErrorObject(result.error, "Non-Error thrown");
}
if (result.value?.status !== "ok") {
throw new Error(`agent run did not complete successfully: ${JSON.stringify(result.value)}`);
}
if (result.value?.status !== "ok") {
throw new Error(`agent run did not complete successfully: ${JSON.stringify(result.value)}`);
}
function toLintErrorObject(value, fallbackMessage) {
@@ -111,17 +84,3 @@ function toLintErrorObject(value, fallbackMessage) {
}
return error;
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
try {
await main();
} catch (error) {
console.error(error instanceof Error ? error.stack || error.message : String(error));
process.exit(1);
}
}
export const testing = {
DEFAULT_RAW_SCHEMA_ERROR,
validateRejectResult,
};

View File

@@ -22,8 +22,14 @@ mock_pid=""
gateway_pid=""
cleanup() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
openclaw_e2e_stop_process "${mock_pid:-}"
if [ -n "${gateway_pid:-}" ] && kill -0 "$gateway_pid" 2>/dev/null; then
kill "$gateway_pid" 2>/dev/null || true
wait "$gateway_pid" 2>/dev/null || true
fi
if [ -n "${mock_pid:-}" ] && kill -0 "$mock_pid" 2>/dev/null; then
kill "$mock_pid" 2>/dev/null || true
wait "$mock_pid" 2>/dev/null || true
fi
}
trap cleanup EXIT
@@ -67,8 +73,22 @@ for _ in $(seq 1 80); do
done
node -e "fetch('http://127.0.0.1:${MOCK_PORT}/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))" >/dev/null
gateway_pid="$(openclaw_e2e_start_gateway "$entry" "$PORT" "$GATEWAY_LOG")"
openclaw_e2e_wait_gateway_ready "$gateway_pid" "$GATEWAY_LOG" 360
node "$entry" gateway --port "$PORT" --bind loopback --allow-unconfigured >"$GATEWAY_LOG" 2>&1 &
gateway_pid="$!"
for _ in $(seq 1 360); do
if ! kill -0 "$gateway_pid" 2>/dev/null; then
echo "gateway exited before listening" >&2
exit 1
fi
if node "$entry" gateway health \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \
--timeout 120000 \
--json >/dev/null 2>&1; then
break
fi
sleep 0.25
done
node "$entry" gateway health \
--url "ws://127.0.0.1:$PORT" \
--token "$TOKEN" \

View File

@@ -20,25 +20,6 @@ package_name="@openclaw/lifecycle-claw"
probe="scripts/e2e/lib/plugin-lifecycle-matrix/probe.mjs"
measure="scripts/e2e/lib/plugin-lifecycle-matrix/measure.mjs"
resource_dir="/tmp/openclaw-plugin-lifecycle-matrix"
pack_root=""
registry_root=""
cleanup() {
openclaw_plugins_cleanup_fixture_servers
rm -rf "$resource_dir"
if [ -n "$pack_root" ]; then
rm -rf "$pack_root"
fi
if [ -n "$registry_root" ]; then
rm -rf "$registry_root"
fi
rm -f \
/tmp/lifecycle-claw-1.0.0.tgz \
/tmp/lifecycle-claw-2.0.0.tgz \
/tmp/plugin-lifecycle-inspect-v1.json
}
trap cleanup EXIT
mkdir -p "$resource_dir"
summary_tsv="$resource_dir/resource-summary.tsv"
printf "phase\tmax_rss_kb\tcpu_seconds\twall_ms\tcpu_core_ratio\tsignal\n" >"$summary_tsv"
@@ -56,7 +37,6 @@ registry_root="$(mktemp -d "/tmp/openclaw-plugin-lifecycle-registry.XXXXXX")"
pack_fixture_plugin "$pack_root/v1" /tmp/lifecycle-claw-1.0.0.tgz "$plugin_id" 1.0.0 lifecycle.v1 "Lifecycle Claw"
pack_fixture_plugin "$pack_root/v2" /tmp/lifecycle-claw-2.0.0.tgz "$plugin_id" 2.0.0 lifecycle.v2 "Lifecycle Claw"
start_npm_fixture_registry "$package_name" 1.0.0 /tmp/lifecycle-claw-1.0.0.tgz "$registry_root" "$package_name" 2.0.0 /tmp/lifecycle-claw-2.0.0.tgz
trap cleanup EXIT
run_measured install-v1 node "$entry" plugins install "npm:$package_name@1.0.0"
node "$probe" assert-version "$plugin_id" 1.0.0

View File

@@ -19,7 +19,7 @@ node "$probe" seed
node scripts/e2e/lib/plugin-update/registry-server.mjs >/tmp/openclaw-e2e-registry.log 2>&1 &
registry_pid=$!
trap 'openclaw_e2e_stop_process "${registry_pid:-}"' EXIT
trap 'kill "$registry_pid" >/dev/null 2>&1 || true' EXIT
if ! node "$probe" wait-registry; then
echo "Local npm metadata registry failed to start"

View File

@@ -26,13 +26,9 @@ export SUCCESS_MARKER MOCK_REQUEST_LOG
mock_pid=""
gateway_pid=""
media_root=""
cleanup() {
openclaw_e2e_terminate_gateways "${gateway_pid:-}"
openclaw_e2e_stop_process "${mock_pid:-}"
if [ -n "${media_root:-}" ]; then
rm -rf "$media_root"
fi
}
trap cleanup EXIT
@@ -115,12 +111,11 @@ openclaw plugins list --json >/tmp/openclaw-release-media-memory-plugins.json \
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-file-contains /tmp/openclaw-release-media-memory-plugins.json memory-core
node scripts/e2e/lib/release-scenarios/assertions.mjs configure-mock-openai "$MOCK_PORT"
mkdir -p "$OPENCLAW_STATE_DIR/workspace/memory"
media_root="$(mktemp -d /tmp/openclaw-release-media-memory.XXXXXX)"
printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yf7kAAAAASUVORK5CYII=' | base64 -d >"$media_root/input.png"
mkdir -p "$OPENCLAW_STATE_DIR/workspace/memory" /tmp/openclaw-release-media-memory
printf '%s' 'iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8/x8AAwMCAO+yf7kAAAAASUVORK5CYII=' | base64 -d > /tmp/openclaw-release-media-memory/input.png
openclaw infer image describe \
--file "$media_root/input.png" \
--file /tmp/openclaw-release-media-memory/input.png \
--model openai/gpt-5.5 \
--prompt "Describe this image and return marker $SUCCESS_MARKER" \
--json >/tmp/openclaw-release-media-memory-describe.json 2>/tmp/openclaw-release-media-memory-describe.stderr.log
@@ -129,7 +124,7 @@ node scripts/e2e/lib/release-scenarios/assertions.mjs assert-image-describe /tmp
openclaw infer image generate \
--model openai/gpt-image-1 \
--prompt "Generate a tiny test image for $SUCCESS_MARKER" \
--output "$media_root/generated.png" \
--output /tmp/openclaw-release-media-memory/generated.png \
--json >/tmp/openclaw-release-media-memory-generate.json 2>/tmp/openclaw-release-media-memory-generate.stderr.log
node scripts/e2e/lib/release-scenarios/assertions.mjs assert-image-generate /tmp/openclaw-release-media-memory-generate.json "$MOCK_REQUEST_LOG"

View File

@@ -222,14 +222,6 @@ export function isDependencyGuardAuthorizedForHead(comment, currentHeadSha) {
);
}
export function isDependencyGuardTrustedForHead(comment, currentHeadSha) {
return (
Boolean(currentHeadSha) &&
comment?.body?.includes("### Dependency graph changes noted") === true &&
dependencyGuardCommentHeadSha(comment) === currentHeadSha
);
}
export function securityApproverSet(value) {
return new Set(
String(value ?? "")
@@ -285,7 +277,7 @@ export function renderAuthorizedDependencyComment(override) {
"",
"### Dependency graph change authorized",
"",
"This PR includes dependency graph changes. A repository admin or member of `@openclaw/openclaw-secops` authorized this exact head SHA with `/allow-dependencies-change`.",
"This PR includes dependency graph changes. A member of `@openclaw/openclaw-secops` authorized this exact head SHA with `/allow-dependencies-change`.",
"",
`- Approved SHA: ${markdownCode(override.sha)}`,
`- Approved by: @${sanitizeDisplayValue(override.login)}`,
@@ -297,22 +289,6 @@ export function renderAuthorizedDependencyComment(override) {
return lines.join("\n");
}
export function renderTrustedDependencyComment({ actor, headSha }) {
return [
dependencyGraphGuardMarker,
"",
"### Dependency graph changes noted",
"",
"This PR includes dependency graph changes. The dependency guard is informational because the PR author is a repository admin or a member of `@openclaw/openclaw-secops`.",
"",
`- Current SHA: ${markdownCode(headSha ?? "<head-sha>")}`,
`- Trusted actor: @${sanitizeDisplayValue(actor.login)}`,
`- Trusted role: ${markdownCode(actor.reason)}`,
"",
"Security review is still recommended before merge when the dependency graph change is intentional.",
].join("\n");
}
export function renderAutoscrubbedDependencyComment({ baseBranch, lockfileChanges, commitSha }) {
const safeBranch = sanitizeDisplayValue(baseBranch ?? "main");
const fileLines = lockfileChanges.map((path) => `- ${markdownCode(path)}`);
@@ -385,14 +361,14 @@ export function renderBlockedDependencyComment({
"",
"### Dependency graph changes are blocked",
"",
"OpenClaw does not accept dependency graph changes through PRs unless a repository admin or security explicitly authorizes the current head SHA. Dependency updates are generated internally by maintainers so external PRs cannot change the resolved graph.",
"OpenClaw does not accept dependency graph changes through PRs unless security explicitly authorizes the current head SHA. Dependency updates are generated internally by maintainers so external PRs cannot change the resolved graph.",
"",
"Detected dependency graph changes:",
...reasons,
...autoscrubLines,
...removalSteps,
"",
"If this PR intentionally needs a dependency graph change, ask a repository admin or member of `@openclaw/openclaw-secops` to comment:",
"If this PR intentionally needs a dependency graph change, ask a member of `@openclaw/openclaw-secops` to comment:",
"",
"```text",
allowDependenciesCommand,
@@ -439,44 +415,6 @@ function renderAutoscrubStatusLines(status) {
return [];
}
export function dependencyGuardTrustedActorCandidates({ pullRequest, event, currentHeadSha }) {
const eventHeadSha = event?.pull_request?.head?.sha;
const eventAfterSha = event?.after;
const eventMatchesCurrentHead =
Boolean(currentHeadSha) &&
(eventHeadSha === currentHeadSha || eventAfterSha === currentHeadSha);
if (!eventMatchesCurrentHead) {
return [];
}
const candidates = [];
const seen = new Set();
for (const [source, login] of [["pull request author", pullRequest?.user?.login]]) {
if (typeof login !== "string" || login.length === 0) {
continue;
}
const normalizedLogin = login.toLowerCase();
if (seen.has(normalizedLogin)) {
continue;
}
seen.add(normalizedLogin);
candidates.push({ login, source });
}
return candidates;
}
export async function findTrustedDependencyGuardActor({ candidates, isDependencyApprover }) {
for (const candidate of candidates) {
const role = await isDependencyApprover(candidate.login);
if (role) {
return {
login: candidate.login,
reason: `${candidate.source}; ${role}`,
};
}
}
return null;
}
function renderManifestChangeLine(change) {
return `- ${markdownCode(change.path)} changed ${change.fields.map(markdownCode).join(", ")}.`;
}
@@ -856,98 +794,6 @@ async function main() {
return;
}
const membershipCache = new Map();
const permissionCache = new Map();
const isSecurityMember = async (login) => {
const normalizedLogin = login.toLowerCase();
if (explicitSecurityApprovers.has(normalizedLogin)) {
return true;
}
if (membershipCache.has(normalizedLogin)) {
return membershipCache.get(normalizedLogin);
}
try {
const membership = await api.request(
`/orgs/${owner}/teams/${securityTeamSlug}/memberships/${encodeURIComponent(login)}`,
);
const allowed = membership?.state === "active";
membershipCache.set(normalizedLogin, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
console.warn(`Could not verify ${login} against ${securityTeamSlug}: ${error.message}`);
}
membershipCache.set(normalizedLogin, false);
return false;
}
};
const isRepositoryAdmin = async (login) => {
const normalizedLogin = login.toLowerCase();
if (permissionCache.has(normalizedLogin)) {
return permissionCache.get(normalizedLogin);
}
try {
const result = await api.request(
`/repos/${owner}/${repo}/collaborators/${encodeURIComponent(login)}/permission`,
);
const allowed = result?.permission === "admin";
permissionCache.set(normalizedLogin, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
console.warn(`Could not verify repository permission for ${login}: ${error.message}`);
}
permissionCache.set(normalizedLogin, false);
return false;
}
};
const isDependencyApprover = async (login) => {
if (await isSecurityMember(login)) {
return securityTeamSlug;
}
if (await isRepositoryAdmin(login)) {
return "repository admin";
}
return null;
};
const currentHeadSha = pullRequest.head?.sha;
if (isDependencyGuardTrustedForHead(existingGuardComment, currentHeadSha)) {
if (mode === "detect") {
await setOutput("autoscrub", "false");
}
await writeSummary(
[
"## Dependency Guard",
"",
`Dependency graph change remains informational for a trusted actor at ${markdownCode(currentHeadSha)}.`,
].join("\n"),
);
console.log("Dependency graph change remains informational for this head SHA.");
return;
}
const trustedActor = await findTrustedDependencyGuardActor({
candidates: dependencyGuardTrustedActorCandidates({ pullRequest, event, currentHeadSha }),
isDependencyApprover,
});
if (trustedActor) {
if (mode === "detect") {
await setOutput("autoscrub", "false");
}
await upsertComment(
existingGuardComment,
renderTrustedDependencyComment({ actor: trustedActor, headSha: currentHeadSha }),
);
await writeSummary(
[
"## Dependency Guard",
"",
`Dependency graph change noted for trusted actor @${sanitizeDisplayValue(trustedActor.login)} and allowed to continue.`,
].join("\n"),
);
console.log("Dependency graph change noted for trusted actor; guard is informational.");
return;
}
const autoscrubCandidate = shouldAutoscrubDependencyLockfiles({
dependencyFiles,
lockfileChanges,
@@ -1045,6 +891,32 @@ async function main() {
};
}
}
const membershipCache = new Map();
const isSecurityMember = async (login) => {
const normalizedLogin = login.toLowerCase();
if (explicitSecurityApprovers.size > 0) {
return explicitSecurityApprovers.has(normalizedLogin);
}
if (membershipCache.has(login)) {
return membershipCache.get(login);
}
try {
const membership = await api.request(
`/orgs/${owner}/teams/${securityTeamSlug}/memberships/${encodeURIComponent(login)}`,
);
const allowed = membership?.state === "active";
membershipCache.set(login, allowed);
return allowed;
} catch (error) {
if (error?.status !== 404) {
console.warn(`Could not verify ${login} against ${securityTeamSlug}: ${error.message}`);
}
membershipCache.set(login, false);
return false;
}
};
const currentHeadSha = pullRequest.head?.sha;
if (isDependencyGuardAuthorizedForHead(existingGuardComment, currentHeadSha)) {
await writeSummary(
[
@@ -1059,7 +931,7 @@ async function main() {
const override = await findDependencyOverrideCommandAsync({
comments,
expectedSha: dependencyOverrideExpectedSha(existingGuardComment, currentHeadSha),
isSecurityMember: async (login) => Boolean(await isDependencyApprover(login)),
isSecurityMember,
newerThan: existingGuardComment?.updated_at ?? existingGuardComment?.created_at,
});
if (override) {
@@ -1071,7 +943,7 @@ async function main() {
`Dependency graph change authorized by @${sanitizeDisplayValue(override.login)} for ${markdownCode(override.sha)}.`,
].join("\n"),
);
console.log("Dependency graph change authorized by trusted override.");
console.log("Dependency graph change authorized by security override.");
return;
}
@@ -1086,11 +958,9 @@ async function main() {
}),
);
await writeSummary(
"## Dependency Guard\n\nDependency graph changes are blocked without a current admin or secops override.",
);
throw new Error(
"Dependency graph changes require removal or a current admin or secops override.",
"## Dependency Guard\n\nDependency graph changes are blocked without a current secops override.",
);
throw new Error("Dependency graph changes require removal or a current secops override.");
}
if (import.meta.url === `file://${process.argv[1]}`) {

View File

@@ -6,7 +6,6 @@ import assert from "node:assert/strict";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { testing as voiceCallCliTesting } from "../../extensions/voice-call/src/cli.ts";
import { loadSessionLogs, loadSessionUsageTimeSeries } from "../../src/infra/session-cost-usage.ts";
import {
@@ -15,15 +14,6 @@ import {
resetDiagnosticPhasesForTest,
} from "../../src/logging/diagnostic-phase.ts";
export async function withProofTempRoot(callback) {
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-proof-"));
try {
return await callback(root);
} finally {
fs.rmSync(root, { force: true, recursive: true });
}
}
async function main() {
resetDiagnosticPhasesForTest();
recordDiagnosticPhase({
@@ -50,80 +40,77 @@ async function main() {
assert.equal(zeroPhases.length, 0);
console.log("getRecentDiagnosticPhases(0).length =", zeroPhases.length);
await withProofTempRoot(async (root) => {
const sessionFile = path.join(root, "s.jsonl");
fs.writeFileSync(
sessionFile,
[
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "a" },
}),
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:01:00.000Z",
message: {
role: "assistant",
content: "b",
provider: "openai",
model: "gpt-5.5",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { total: 0.001 },
},
const root = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-proof-"));
const sessionFile = path.join(root, "s.jsonl");
fs.writeFileSync(
sessionFile,
[
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:00:00.000Z",
message: { role: "user", content: "a" },
}),
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:01:00.000Z",
message: {
role: "assistant",
content: "b",
provider: "openai",
model: "gpt-5.5",
usage: {
input: 1,
output: 2,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 3,
cost: { total: 0.001 },
},
}),
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:02:00.000Z",
message: {
role: "assistant",
content: "c",
provider: "openai",
model: "gpt-5.5",
usage: {
input: 3,
output: 4,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 7,
cost: { total: 0.002 },
},
},
}),
JSON.stringify({
type: "message",
timestamp: "2026-01-01T00:02:00.000Z",
message: {
role: "assistant",
content: "c",
provider: "openai",
model: "gpt-5.5",
usage: {
input: 3,
output: 4,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 7,
cost: { total: 0.002 },
},
}),
].join("\n"),
);
},
}),
].join("\n"),
);
const logs = await loadSessionLogs({ sessionFile, limit: 0 });
const series = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 0 });
const positiveLogs = await loadSessionLogs({ sessionFile, limit: 10 });
const positiveSeries = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 10 });
assert.equal(logs?.length, 0);
assert.equal(series.points.length, 0);
assert.equal(positiveLogs?.length, 3);
assert.equal(positiveSeries.points.length, 2);
console.log("loadSessionLogs({ limit: 0 }).length =", logs?.length);
console.log(
"loadSessionUsageTimeSeries({ maxPoints: 0 }).points.length =",
series?.points.length,
);
const logs = await loadSessionLogs({ sessionFile, limit: 0 });
const series = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 0 });
const positiveLogs = await loadSessionLogs({ sessionFile, limit: 10 });
const positiveSeries = await loadSessionUsageTimeSeries({ sessionFile, maxPoints: 10 });
assert.equal(logs?.length, 0);
assert.equal(series.points.length, 0);
assert.equal(positiveLogs?.length, 3);
assert.equal(positiveSeries.points.length, 2);
console.log("loadSessionLogs({ limit: 0 }).length =", logs?.length);
console.log(
"loadSessionUsageTimeSeries({ maxPoints: 0 }).points.length =",
series?.points.length,
);
try {
voiceCallCliTesting.parseVoiceCallIntOption("nope", "--port", { min: 1 });
assert.fail("expected invalid voicecall --port value to throw");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
assert.equal(message, "Invalid numeric value for --port: nope");
console.log("parseVoiceCallIntOption('nope', '--port') error:", message);
}
});
try {
voiceCallCliTesting.parseVoiceCallIntOption("nope", "--port", { min: 1 });
assert.fail("expected invalid voicecall --port value to throw");
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
assert.equal(message, "Invalid numeric value for --port: nope");
console.log("parseVoiceCallIntOption('nope', '--port') error:", message);
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}
await main();

View File

@@ -6,7 +6,6 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { pathToFileURL } from "node:url";
import { maybeApplyTtsToPayload } from "../../packages/speech-core/src/tts.ts";
import { buildWebchatAudioContentBlocksFromReplyPayloads } from "../../src/gateway/server-methods/chat-webchat-media.ts";
import { createPluginRecord } from "../../src/plugins/loader-records.ts";
@@ -23,16 +22,8 @@ const noopLogger = {
debug() {},
};
export function cleanupProofArtifacts({ mediaPath, prefsPath }) {
if (mediaPath) {
fs.rmSync(path.dirname(mediaPath), { recursive: true, force: true });
}
fs.rmSync(prefsPath, { force: true });
}
async function main() {
resetPluginRuntimeStateForTest();
let mediaPath;
const pluginRegistry = createPluginRegistry({
logger: noopLogger,
runtime: {},
@@ -71,65 +62,65 @@ async function main() {
},
};
const accumulatedBlockText =
"WebChat streams block text; dispatch synthesizes one TTS tail with kind final.";
const blockResult = await maybeApplyTtsToPayload({
payload: { text: accumulatedBlockText },
cfg,
channel: "webchat",
kind: "block",
});
console.log("maybeApplyTtsToPayload(kind=block).mediaUrl =", blockResult.mediaUrl ?? "(none)");
const tailResult = await maybeApplyTtsToPayload({
payload: { text: accumulatedBlockText },
cfg,
channel: "webchat",
kind: "final",
});
console.log("maybeApplyTtsToPayload(kind=final).mediaUrl =", tailResult.mediaUrl ?? "(none)");
console.log(
"maybeApplyTtsToPayload(kind=final).trustedLocalMedia =",
tailResult.trustedLocalMedia ?? false,
);
const mediaPath = tailResult.mediaUrl;
if (!mediaPath || !fs.existsSync(mediaPath)) {
throw new Error("expected final-mode tail TTS to write a local media file");
}
// Same shape as dispatch-from-config accumulated block TTS-only final payload.
const ttsOnlyPayload = {
mediaUrl: tailResult.mediaUrl,
audioAsVoice: tailResult.audioAsVoice,
spokenText: accumulatedBlockText,
trustedLocalMedia: true,
};
console.log("dispatch ttsOnlyPayload.trustedLocalMedia =", ttsOnlyPayload.trustedLocalMedia);
const localRoots = [path.dirname(mediaPath)];
const trustedBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads([ttsOnlyPayload], {
localRoots,
});
const untrustedBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
[{ mediaUrl: mediaPath }],
{ localRoots },
);
console.log(
"buildWebchatAudioContentBlocksFromReplyPayloads(ttsOnlyPayload).length =",
trustedBlocks.length,
);
console.log(
"buildWebchatAudioContentBlocksFromReplyPayloads(untrusted).length =",
untrustedBlocks.length,
);
fs.rmSync(path.dirname(mediaPath), { recursive: true, force: true });
try {
const accumulatedBlockText =
"WebChat streams block text; dispatch synthesizes one TTS tail with kind final.";
const blockResult = await maybeApplyTtsToPayload({
payload: { text: accumulatedBlockText },
cfg,
channel: "webchat",
kind: "block",
});
console.log("maybeApplyTtsToPayload(kind=block).mediaUrl =", blockResult.mediaUrl ?? "(none)");
const tailResult = await maybeApplyTtsToPayload({
payload: { text: accumulatedBlockText },
cfg,
channel: "webchat",
kind: "final",
});
console.log("maybeApplyTtsToPayload(kind=final).mediaUrl =", tailResult.mediaUrl ?? "(none)");
console.log(
"maybeApplyTtsToPayload(kind=final).trustedLocalMedia =",
tailResult.trustedLocalMedia ?? false,
);
mediaPath = tailResult.mediaUrl;
if (!mediaPath || !fs.existsSync(mediaPath)) {
throw new Error("expected final-mode tail TTS to write a local media file");
}
// Same shape as dispatch-from-config accumulated block TTS-only final payload.
const ttsOnlyPayload = {
mediaUrl: tailResult.mediaUrl,
audioAsVoice: tailResult.audioAsVoice,
spokenText: accumulatedBlockText,
trustedLocalMedia: true,
};
console.log("dispatch ttsOnlyPayload.trustedLocalMedia =", ttsOnlyPayload.trustedLocalMedia);
const localRoots = [path.dirname(mediaPath)];
const trustedBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads([ttsOnlyPayload], {
localRoots,
});
const untrustedBlocks = await buildWebchatAudioContentBlocksFromReplyPayloads(
[{ mediaUrl: mediaPath }],
{ localRoots },
);
console.log(
"buildWebchatAudioContentBlocksFromReplyPayloads(ttsOnlyPayload).length =",
trustedBlocks.length,
);
console.log(
"buildWebchatAudioContentBlocksFromReplyPayloads(untrusted).length =",
untrustedBlocks.length,
);
} finally {
cleanupProofArtifacts({ mediaPath, prefsPath });
resetPluginRuntimeStateForTest();
fs.unlinkSync(prefsPath);
} catch {
// optional prefs file
}
}
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
await main();
}
await main();

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

@@ -1,126 +0,0 @@
import { describe, expect, it, vi } from "vitest";
const LOGIN_HINT_SENTINEL = "<<login-hint-for-provider>>";
vi.mock("../provider-auth-recovery-hint.js", () => ({
buildProviderAuthRecoveryHint: (params: { provider: string }) =>
`${LOGIN_HINT_SENTINEL}:${params.provider}`,
}));
import type { FailoverReason } from "../embedded-agent-helpers/types.js";
import { formatAuthProfileFailureMessage } from "./failure-copy.js";
const PROVIDER = "openai-codex";
const REASONS_WITH_RECOVERY: readonly FailoverReason[] = [
"auth",
"session_expired",
"auth_permanent",
"billing",
];
const REASONS_TRANSIENT: readonly FailoverReason[] = [
"rate_limit",
"overloaded",
"timeout",
"server_error",
"model_not_found",
];
describe("formatAuthProfileFailureMessage", () => {
describe("recovery-hint dispatch", () => {
it("includes the login command for reasons the user can act on", () => {
for (const reason of REASONS_WITH_RECOVERY) {
const message = formatAuthProfileFailureMessage({
reason,
provider: PROVIDER,
allInCooldown: true,
});
expect(message, `reason=${reason}`).toContain(`${LOGIN_HINT_SENTINEL}:${PROVIDER}`);
}
});
it("omits the login command for transient cooldown reasons", () => {
for (const reason of REASONS_TRANSIENT) {
const message = formatAuthProfileFailureMessage({
reason,
provider: PROVIDER,
allInCooldown: true,
});
expect(message, `reason=${reason}`).not.toContain(LOGIN_HINT_SENTINEL);
}
});
});
describe("reason coverage", () => {
it("renders distinct copy across the major reason classes", () => {
const samples = (["auth", "billing", "rate_limit", "timeout"] as const).map((reason) =>
formatAuthProfileFailureMessage({ reason, provider: PROVIDER, allInCooldown: true }),
);
expect(new Set(samples).size).toBe(samples.length);
});
it("always mentions the provider name", () => {
for (const reason of [...REASONS_WITH_RECOVERY, ...REASONS_TRANSIENT, "unknown"] as const) {
const message = formatAuthProfileFailureMessage({
reason,
provider: PROVIDER,
allInCooldown: true,
});
expect(message, `reason=${reason}`).toContain(PROVIDER);
}
});
});
describe("cause handling", () => {
it("returns the cause text verbatim when the reason has no actionable copy", () => {
const cause = new Error("upstream provider returned 502");
const message = formatAuthProfileFailureMessage({
reason: "unknown",
provider: PROVIDER,
allInCooldown: false,
cause,
});
expect(message).toBe(cause.message);
});
it("appends a diagnostic suffix when the cause adds detail beyond the description", () => {
const message = formatAuthProfileFailureMessage({
reason: "auth",
provider: PROVIDER,
allInCooldown: false,
cause: new Error("invalid_grant"),
});
expect(message).toContain("(invalid_grant)");
});
it("does not append a diagnostic suffix when the cause text is already in the description", () => {
// Derive the description sentence by formatting once without a cause, then stripping
// the mocked recovery hint. Using that sentence as the cause should be deduped.
const withoutCause = formatAuthProfileFailureMessage({
reason: "auth",
provider: PROVIDER,
allInCooldown: false,
});
const description = withoutCause
.replace(new RegExp(`\\s*${LOGIN_HINT_SENTINEL}:[^\\s]+\\s*$`), "")
.trim();
const withDuplicateCause = formatAuthProfileFailureMessage({
reason: "auth",
provider: PROVIDER,
allInCooldown: false,
cause: new Error(description),
});
expect(withDuplicateCause).toBe(withoutCause);
});
it("produces non-empty copy for unknown reasons with no cause", () => {
const message = formatAuthProfileFailureMessage({
reason: "unknown",
provider: PROVIDER,
allInCooldown: false,
});
expect(message).toContain(PROVIDER);
expect(message.length).toBeGreaterThan(0);
});
});
});

View File

@@ -1,130 +0,0 @@
import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { formatErrorMessage } from "../../infra/errors.js";
import type { FailoverReason } from "../embedded-agent-helpers/types.js";
import { buildProviderAuthRecoveryHint } from "../provider-auth-recovery-hint.js";
export type AuthProfileFailureCopyParams = {
reason: FailoverReason;
provider: string;
/**
* True when the failure was reached because every configured profile is in
* cooldown / blocked. False when an attempt to use a specific profile threw
* (e.g. credential lookup failed). The two paths produce different copy
* because only the cooldown case implies "wait or rotate"; the other case
* implies "the credential itself is broken".
*/
allInCooldown: boolean;
/**
* Underlying error that triggered the failover, if any. Used to append a
* short diagnostic suffix and to fall back to the original message when no
* structured recovery copy applies.
*/
cause?: unknown;
config?: OpenClawConfig;
workspaceDir?: string;
env?: NodeJS.ProcessEnv;
};
function describeReason(
reason: FailoverReason,
provider: string,
allInCooldown: boolean,
): string | null {
if (allInCooldown) {
switch (reason) {
case "auth":
case "session_expired":
return `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`;
case "auth_permanent":
return `${provider} isn't accepting your saved login anymore.`;
case "billing":
return `${provider} rejected the request — looks like a billing issue on the account.`;
case "rate_limit":
return `${provider} is asking us to slow down. Please wait a moment before trying again.`;
case "overloaded":
return `${provider} is overloaded right now. Please wait a moment before trying again.`;
case "timeout":
return `${provider} hasn't been responding. Please wait a moment before trying again.`;
case "model_not_found":
return `${provider} can't find the model you're using right now.`;
case "server_error":
return `${provider} is having issues right now. Please wait a moment before trying again.`;
default:
return `Couldn't reach ${provider} with any of your saved logins right now.`;
}
}
switch (reason) {
case "auth":
case "session_expired":
return `Couldn't sign in to ${provider}. Your saved login looks expired or no longer works.`;
case "auth_permanent":
return `${provider} isn't accepting your saved login.`;
case "billing":
return `${provider} rejected the request — looks like a billing issue on the account.`;
default:
return null;
}
}
function shouldIncludeRecoveryHint(reason: FailoverReason): boolean {
switch (reason) {
case "auth":
case "auth_permanent":
case "session_expired":
case "billing":
return true;
case "rate_limit":
case "overloaded":
case "timeout":
case "server_error":
case "model_not_found":
return false;
default:
return true;
}
}
function diagnosticSuffix(cause: unknown, primary: string): string | null {
if (cause === undefined || cause === null) {
return null;
}
const text = formatErrorMessage(cause).trim();
if (!text || primary.includes(text)) {
return null;
}
return ` (${text})`;
}
/**
* Single source of truth for user-facing copy when an auth-profile rotation
* fails. Composes a reason-specific sentence with an actionable next-step
* derived from the provider's plugin manifest (`buildProviderAuthRecoveryHint`).
*
* Falls back to the underlying error's text when the reason maps to nothing
* actionable, so we never produce worse copy than the raw error.
*/
export function formatAuthProfileFailureMessage(params: AuthProfileFailureCopyParams): string {
const description = describeReason(params.reason, params.provider, params.allInCooldown);
if (!description) {
const causeText = params.cause ? formatErrorMessage(params.cause).trim() : "";
if (causeText) {
return causeText;
}
return `Couldn't reach ${params.provider} with any of your saved logins right now.`;
}
const hint = shouldIncludeRecoveryHint(params.reason)
? buildProviderAuthRecoveryHint({
provider: params.provider,
config: params.config,
workspaceDir: params.workspaceDir,
env: params.env,
})
: null;
const suffix = diagnosticSuffix(params.cause, description);
const parts = [description];
if (hint) {
parts.push(hint);
}
const message = parts.join(" ");
return suffix ? `${message}${suffix}` : message;
}

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

@@ -7,7 +7,6 @@ import {
isProfileInCooldown,
resolveProfilesUnavailableReason,
} from "../../auth-profiles.js";
import { formatAuthProfileFailureMessage } from "../../auth-profiles/failure-copy.js";
import {
classifyFailoverReason,
isFailoverErrorMessage,
@@ -325,25 +324,16 @@ export function createEmbeddedRunAuthController(params: {
}): never => {
const provider = params.getProvider();
const modelId = params.getModelId();
const messageForReason =
failoverParams.message?.trim() ||
(failoverParams.error ? formatErrorMessage(failoverParams.error).trim() : "");
const reason = resolveAuthProfileFailoverReason({
allInCooldown: failoverParams.allInCooldown,
message: messageForReason,
profileIds: params.profileCandidates,
});
const fallbackMessage = `No available auth profile for ${provider} (all in cooldown or unavailable).`;
const message =
failoverParams.message?.trim() ||
formatAuthProfileFailureMessage({
reason,
provider,
allInCooldown: failoverParams.allInCooldown,
cause: failoverParams.error,
config: params.config,
workspaceDir: params.workspaceDir,
env: process.env,
});
(failoverParams.error ? formatErrorMessage(failoverParams.error).trim() : "") ||
fallbackMessage;
const reason = resolveAuthProfileFailoverReason({
allInCooldown: failoverParams.allInCooldown,
message,
profileIds: params.profileCandidates,
});
if (params.fallbackConfigured) {
throw new FailoverError(message, {
reason,

View File

@@ -291,22 +291,6 @@ describe("memory search config", () => {
expect(resolveMemorySearchSyncConfig(cfg, "main")?.embeddingBatchTimeoutSeconds).toBe(600);
});
it("keeps memory watching enabled by default in gateway mode", () => {
const cfg = asConfig({
gateway: { mode: "local" },
agents: {
defaults: {
memorySearch: {
provider: "openai",
},
},
},
});
expect(resolveMemorySearchConfig(cfg, "main")?.sync.watch).toBe(true);
expect(resolveMemorySearchSyncConfig(cfg, "main")?.watch).toBe(true);
});
it("merges defaults and overrides", () => {
const cfg = asConfig({
agents: {

View File

@@ -111,11 +111,6 @@ export type ResolvedMemorySearchConfig = {
};
export type ResolvedMemorySearchSyncConfig = ResolvedMemorySearchConfig["sync"];
export type MemorySearchResolvePurpose = "default" | "status" | "cli";
export type MemorySearchResolveOptions = {
/** @deprecated No-op; kept for resolver call-site compatibility. */
purpose?: MemorySearchResolvePurpose;
};
const DEFAULT_CHUNK_TOKENS = 400;
const DEFAULT_CHUNK_OVERLAP = 80;
@@ -473,7 +468,6 @@ function resolveSyncConfig(
export function resolveMemorySearchConfig(
cfg: OpenClawConfig,
agentId: string,
_options?: MemorySearchResolveOptions,
): ResolvedMemorySearchConfig | null {
const defaults = cfg.agents?.defaults?.memorySearch;
const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch;
@@ -506,7 +500,6 @@ export function resolveMemorySearchConfig(
export function resolveMemorySearchSyncConfig(
cfg: OpenClawConfig,
agentId: string,
_options?: MemorySearchResolveOptions,
): ResolvedMemorySearchSyncConfig | null {
const defaults = cfg.agents?.defaults?.memorySearch;
const overrides = resolveAgentConfig(cfg, agentId)?.memorySearch;

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

@@ -190,7 +190,6 @@ function summarizeSubagentRuns(runs: ReturnType<typeof listSubagentRunsForReques
endedReason: run.endedReason,
pauseReason: run.pauseReason,
outcome: run.outcome?.status,
outcomeError: run.outcome?.status === "error" ? run.outcome.error : undefined,
delivery: run.delivery?.status,
deliveryError: run.delivery?.lastError,
suppressAnnounceReason: run.suppressAnnounceReason,

View File

@@ -1372,7 +1372,9 @@ describe("steerControlledSubagentRun", () => {
});
setSubagentControlDepsForTest({
callGateway: async <T = Record<string, unknown>>(request: CallGatewayOptions) => {
callGateway: async <T = Record<string, unknown>>(
request: CallGatewayOptions,
) => {
if (request.method === "agent.wait") {
return {} as T;
}
@@ -1417,67 +1419,4 @@ describe("steerControlledSubagentRun", () => {
text: "steered yielded steer task.",
});
});
it("rotates the child session when restarting a previously active session", async () => {
const childSessionKey = "agent:main:subagent:active-steer-worker";
const storePath = writeSessionStoreFixture("steer-restart-session", {
[childSessionKey]: {
sessionId: "old-child-session",
updatedAt: Date.now(),
},
});
const agentCalls: CallGatewayOptions[] = [];
addSubagentRunForTests({
runId: "run-active-steer",
childSessionKey,
controllerSessionKey: "agent:main:main",
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
task: "active steer task",
cleanup: "keep",
createdAt: Date.now() - 5_000,
startedAt: Date.now() - 4_000,
});
setSubagentControlDepsForTest({
callGateway: async <T = Record<string, unknown>>(request: CallGatewayOptions) => {
if (request.method === "agent.wait") {
return {} as T;
}
if (request.method === "agent") {
agentCalls.push(request);
return { runId: "run-active-steer-restarted" } as T;
}
throw new Error(`unexpected method: ${request.method}`);
},
});
const result = await steerControlledSubagentRun({
cfg: cfgWithSessionStore(storePath),
controller: {
controllerSessionKey: "agent:main:main",
callerSessionKey: "agent:main:main",
callerIsSubagent: false,
controlScope: "children",
},
entry: {
runId: "run-active-steer",
childSessionKey,
requesterSessionKey: "agent:main:main",
requesterDisplayKey: "main",
controllerSessionKey: "agent:main:main",
task: "active steer task",
cleanup: "keep",
createdAt: Date.now() - 5_000,
startedAt: Date.now() - 4_000,
},
message: "updated direction",
});
expect(result.status).toBe("accepted");
expect(result.sessionId).toBeTypeOf("string");
expect(result.sessionId).not.toBe("old-child-session");
const agentParams = agentCalls[0]?.params as { sessionId?: string } | undefined;
expect(agentParams?.sessionId).toBe(result.sessionId);
});
});

View File

@@ -527,7 +527,6 @@ export async function steerControlledSubagentRun(params: {
typeof targetSession.entry?.sessionId === "string" && targetSession.entry.sessionId.trim()
? targetSession.entry.sessionId.trim()
: undefined;
const restartSessionId = sessionId ? crypto.randomUUID() : undefined;
if (sessionId) {
const runtime = await resolveSubagentControlRuntime();
@@ -562,7 +561,7 @@ export async function steerControlledSubagentRun(params: {
params: {
message: params.message,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
sessionId,
idempotencyKey,
deliver: false,
channel: INTERNAL_MESSAGE_CHANNEL,
@@ -581,7 +580,7 @@ export async function steerControlledSubagentRun(params: {
status: "error",
runId,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
sessionId,
error,
};
}
@@ -598,7 +597,7 @@ export async function steerControlledSubagentRun(params: {
status: "error",
runId,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
sessionId,
error: "failed to replace steered subagent run",
};
}
@@ -607,7 +606,7 @@ export async function steerControlledSubagentRun(params: {
status: "accepted",
runId,
sessionKey: params.entry.childSessionKey,
sessionId: restartSessionId,
sessionId,
mode: "restart",
label: resolveSubagentLabel(params.entry),
text: `steered ${resolveSubagentLabel(params.entry)}.`,

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;

View File

@@ -1,18 +1,30 @@
import { resolveTimerTimeoutMs } from "../shared/number-coercion.js";
/** Throttled draft streaming loop for preview send/edit updates. */
export type DraftStreamLoop = {
/** Queue the latest draft text and schedule a send/edit when allowed by throttle state. */
update: (text: string) => void;
/** Immediately flush the latest pending text, waiting for any in-flight send first. */
flush: () => Promise<void>;
/** Stop future sends and clear any pending timer/text. */
stop: () => void;
/** Clear pending text without changing throttle or in-flight state. */
resetPending: () => void;
/** Reset throttle timing and cancel the pending timer. */
resetThrottleWindow: () => void;
/** Wait for the current send/edit promise without flushing pending text. */
waitForInFlight: () => Promise<void>;
};
/** Creates a throttled stream loop that serializes draft preview send/edit calls. */
export function createDraftStreamLoop(params: {
/** Minimum delay between successful send/edit attempts. */
throttleMs: number;
/** Stop predicate checked before every flush iteration. */
isStopped: () => boolean;
/** Sends or edits the current draft text; false keeps the text pending for retry. */
sendOrEditStreamMessage: (text: string) => Promise<void | boolean>;
/** Background flush error sink used to avoid unhandled promise rejections. */
onBackgroundFlushError?: (err: unknown) => void;
}): DraftStreamLoop {
const throttleMs = resolveTimerTimeoutMs(params.throttleMs, 0, 0);
@@ -57,6 +69,8 @@ export function createDraftStreamLoop(params: {
throw err;
}
if (sent === false) {
// A false result means the adapter declined this update without throwing; keep it pending
// so a later explicit flush can retry the same latest text.
pendingText = text;
return;
}

View File

@@ -8,34 +8,49 @@ import {
} from "../auto-reply/inbound-debounce.js";
import type { OpenClawConfig } from "../config/types.js";
/** Returns whether an inbound text event may be debounced before agent dispatch. */
export function shouldDebounceTextInbound(params: {
/** Raw text or command body from the inbound event. */
text: string | null | undefined;
/** Config used for command detection and debounce duration. */
cfg: OpenClawConfig;
/** Media-bearing events bypass debounce so attachments are processed promptly. */
hasMedia?: boolean;
/** Command parser options used to detect control commands. */
commandOptions?: CommandNormalizeOptions;
/** Explicit per-channel opt-out. */
allowDebounce?: boolean;
}): boolean {
if (params.allowDebounce === false) {
return false;
}
if (params.hasMedia) {
// Media events can carry upload/download side effects; dispatch them
// immediately so attachment processing is not delayed behind text batching.
return false;
}
const text = normalizeOptionalString(params.text) ?? "";
if (!text) {
return false;
}
// Control commands must run immediately; debouncing them can reorder operator actions.
return !isControlCommandMessage(text, params.cfg, params.commandOptions);
}
/** Creates a channel-specific inbound debouncer using config-derived timing. */
export function createChannelInboundDebouncer<T>(
params: Omit<InboundDebounceCreateParams<T>, "debounceMs"> & {
/** Config used to resolve channel debounce settings. */
cfg: OpenClawConfig;
/** Channel id whose debounce settings should be applied. */
channel: string;
/** Test/runtime override that bypasses config-derived debounce duration. */
debounceMsOverride?: number;
},
): {
/** Resolved debounce duration passed into the debouncer. */
debounceMs: number;
/** Debouncer instance scoped to the channel. */
debouncer: ReturnType<typeof createInboundDebouncer<T>>;
} {
const debounceMs = resolveInboundDebounceMs({
@@ -43,6 +58,8 @@ export function createChannelInboundDebouncer<T>(
channel: params.channel,
overrideMs: params.debounceMsOverride,
});
// Resolve timing once when the channel monitor is created; per-message checks
// only decide whether an event is debounceable, not what timer to use.
const { cfg: _cfg, channel: _channel, debounceMsOverride: _override, ...rest } = params;
const debouncer = createInboundDebouncer<T>({
debounceMs,

View File

@@ -52,6 +52,7 @@ type BuildAccessFacts = Omit<AccessFacts, "commands"> & {
commands?: Partial<NonNullable<AccessFacts["commands"]>>;
};
/** Normalized channel facts used to build the legacy templating context for one inbound event. */
export type BuildChannelInboundEventContextParams = {
channel: string;
accountId?: string;
@@ -87,6 +88,7 @@ type UntrustedStructuredContextEntries = NonNullable<
FinalizedMsgContext["UntrustedStructuredContext"]
>;
/** Finalized context shape consumed by auto-reply templating and channel turn dispatch. */
export type BuiltChannelInboundEventContext = FinalizedMsgContext & {
Body: string;
BodyForAgent: string;
@@ -156,6 +158,7 @@ function keepSupplementalContext(params: {
});
}
/** Apply visibility policy to quote, forwarded, and thread supplemental context. */
export function filterChannelInboundSupplementalContext(params: {
supplemental?: SupplementalContextFacts;
contextVisibility?: ContextVisibilityMode;
@@ -194,6 +197,7 @@ export function filterChannelInboundSupplementalContext(params: {
};
}
/** Filter only quoted-message context while preserving the shared visibility policy. */
export function filterChannelInboundQuoteContext(
contextVisibility: ContextVisibilityMode | undefined,
quote: SupplementalContextFacts["quote"] | undefined,
@@ -250,6 +254,7 @@ function resolveChannelInboundSupplementalForFinalizer(params: {
const suppressSelfQuoteBody = params.suppressSelfQuoteBody ?? true;
const suppressSelfQuoteMedia = params.suppressSelfQuoteMedia ?? true;
const finalizeQuote = (quoteMedia?: readonly InboundMediaFacts[] | null) => {
// Self-quote media is already present on the current message; appending it would duplicate attachments.
if (!(selfQuote && suppressSelfQuoteMedia)) {
media.push(...(quoteMedia ?? []));
}
@@ -381,6 +386,7 @@ export function finalizeChannelInboundContext<T extends Record<string, unknown>>
return isPromiseLike(prepared) ? prepared.then(finish) : finish(prepared);
}
/** Prefer explicit authorization, then legacy authorizer arrays for older channel callers. */
function resolveAccessFactsCommandAuthorized(
access: BuildAccessFacts | undefined,
): boolean | undefined {
@@ -425,6 +431,7 @@ function resolveUntrustedStructuredContext(params: {
return entries.length > 0 ? entries : undefined;
}
/** Build command-turn metadata exposed to agents from normalized inbound command facts. */
function resolveChannelCommandContext(params: {
command?: CommandFacts;
commandTurn?: CommandTurnContext;
@@ -449,6 +456,7 @@ function resolveChannelCommandContext(params: {
});
}
/** Build and finalize the full inbound event context passed into channel turns. */
export function buildChannelInboundEventContext(
params: BuildChannelInboundEventContextAsyncParams,
): Promise<BuiltChannelInboundEventContext>;

View File

@@ -1,20 +1,32 @@
/** Minimal logger shape accepted by shared channel diagnostics helpers. */
export type LogFn = (message: string) => void;
/** Logs a dropped inbound message using the shared channel/target format. */
export function logInboundDrop(params: {
/** Logger supplied by the channel runtime. */
log: LogFn;
/** Human-readable channel id included at the start of the line. */
channel: string;
/** Compact drop reason suitable for low-volume operator logs. */
reason: string;
/** Optional conversation or recipient target used to disambiguate drops. */
target?: string;
}): void {
const target = params.target ? ` target=${params.target}` : "";
params.log(`${params.channel}: drop ${params.reason}${target}`);
}
/** Logs non-fatal typing feedback failures without interrupting reply delivery. */
export function logTypingFailure(params: {
/** Logger supplied by the channel runtime. */
log: LogFn;
/** Human-readable channel id included at the start of the line. */
channel: string;
/** Optional conversation or recipient target used to disambiguate the failure. */
target?: string;
/** Typing action that failed when the channel reports start/stop separately. */
action?: "start" | "stop";
/** Original channel/API error to stringify for diagnostics. */
error: unknown;
}): void {
const target = params.target ? ` target=${params.target}` : "";
@@ -22,10 +34,15 @@ export function logTypingFailure(params: {
params.log(`${params.channel} typing${action} failed${target}: ${String(params.error)}`);
}
/** Logs non-fatal acknowledgement cleanup failures after message handling continues. */
export function logAckFailure(params: {
/** Logger supplied by the channel runtime. */
log: LogFn;
/** Human-readable channel id included at the start of the line. */
channel: string;
/** Optional conversation or recipient target used to disambiguate the failure. */
target?: string;
/** Original channel/API error to stringify for diagnostics. */
error: unknown;
}): void {
const target = params.target ? ` target=${params.target}` : "";

View File

@@ -32,24 +32,38 @@ export type MentionGateWithBypassResult = MentionGateResult & {
};
export type InboundImplicitMentionKind =
/** Message replied directly to a bot-authored message. */
| "reply_to_bot"
/** Message quoted bot-authored content. */
| "quoted_bot"
/** Message arrived in a thread where the bot is already a participant. */
| "bot_thread_participant"
/** Channel-native mention signal normalized by legacy callers. */
| "native";
export type InboundMentionFacts = {
/** True when the channel can reliably detect explicit mentions. */
canDetectMention: boolean;
/** True when the inbound message explicitly mentioned the bot. */
wasMentioned: boolean;
/** True when the message mentioned anyone, used to avoid command bypass ambiguity. */
hasAnyMention?: boolean;
/** Channel-derived implicit mention reasons that may satisfy mention gating. */
implicitMentionKinds?: readonly InboundImplicitMentionKind[];
};
export type InboundMentionPolicy = {
/** True for group-like conversations where mention gating applies. */
isGroup: boolean;
/** True when the channel/account requires bot mentions before responding. */
requireMention: boolean;
/** Optional allowlist limiting which implicit mention reasons count as mentions. */
allowedImplicitMentionKinds?: readonly InboundImplicitMentionKind[];
/** True when text control commands are enabled for this surface. */
allowTextCommands: boolean;
/** True when the inbound text contains a recognized control command. */
hasControlCommand: boolean;
/** True when access policy allows the sender to run the control command. */
commandAuthorized: boolean;
};
@@ -57,7 +71,9 @@ export type InboundMentionPolicy = {
export type ResolveInboundMentionDecisionFlatParams = InboundMentionFacts & InboundMentionPolicy;
export type ResolveInboundMentionDecisionNestedParams = {
/** Observed mention facts from the inbound message. */
facts: InboundMentionFacts;
/** Channel/account policy used to interpret the mention facts. */
policy: InboundMentionPolicy;
};
@@ -66,8 +82,11 @@ export type ResolveInboundMentionDecisionParams =
| ResolveInboundMentionDecisionNestedParams;
export type InboundMentionDecision = MentionGateResult & {
/** True when at least one allowed implicit mention reason matched. */
implicitMention: boolean;
/** Deduped implicit mention reasons accepted by policy. */
matchedImplicitMentionKinds: InboundImplicitMentionKind[];
/** True when an authorized group control command bypassed explicit mention gating. */
shouldBypassMention: boolean;
};
@@ -168,10 +187,12 @@ function normalizeMentionDecisionParams(
};
}
/** Resolves whether mention policy allows, skips, or command-bypasses one inbound message. */
export function resolveInboundMentionDecision(
params: ResolveInboundMentionDecisionParams,
): InboundMentionDecision {
const { facts, policy } = normalizeMentionDecisionParams(params);
// Authorized text commands may bypass mention gating only when the message names no one else.
const shouldBypassMention =
policy.isGroup &&
policy.requireMention &&

View File

@@ -48,6 +48,7 @@ function resolveProviderMentionPatternsPolicy(
return isMentionPatternsPolicyConfig(policy) ? policy : undefined;
}
/** Resolve provider-scoped mention-pattern gating, with deny entries winning over allow entries. */
export function resolveMentionPatternPolicy(
params: ResolveMentionPatternPolicyParams,
): ResolvedMentionPatternPolicy {

View File

@@ -8,6 +8,7 @@ import type {
ResolvedIngressAllowlist,
} from "./types.js";
/** Returns the highest-priority access-group failure reason for one resolved allowlist. */
export function allowlistFailureReason(
allowlist: ResolvedIngressAllowlist,
): IngressReasonCode | null {
@@ -23,6 +24,7 @@ export function allowlistFailureReason(
return null;
}
/** Builds diagnostics that expose counts and opaque ids without raw allowlist values. */
export function redactedAllowlistDiagnostics(
allowlist: ResolvedIngressAllowlist,
reasonCode: IngressReasonCode,
@@ -72,6 +74,7 @@ function mergeResolvedAllowlists(
};
}
/** Removes dangerous mutable identifier matches unless policy explicitly enables them. */
export function applyMutableIdentifierPolicy(
allowlist: ResolvedIngressAllowlist,
policy: ChannelIngressPolicyInput,
@@ -109,6 +112,7 @@ export function applyMutableIdentifierPolicy(
};
}
/** Resolves the effective group sender allowlist after fallback and route sender policy. */
export function effectiveGroupSenderAllowlist(params: {
state: ChannelIngressState;
policy: ChannelIngressPolicyInput;

View File

@@ -275,6 +275,7 @@ export function decideChannelIngress(
commandGate: commandGate({ state, policy: { ...policy, command: undefined } }),
})
: null;
// Pre-sender activation cannot depend on command auth, so command facts are deliberately absent.
if (activationBeforeSender) {
gates.push(activationBeforeSender);
if (activationBeforeSender.effect === "skip") {

View File

@@ -14,12 +14,14 @@ function accessGroupNames(entries: readonly (string | number)[]): string[] {
);
}
/** Extracts every referenced access-group name from raw allowlist entry groups. */
export function allReferencedAccessGroupNames(
entries: Array<readonly (string | number)[]>,
): string[] {
return uniqueStrings(entries.flatMap((entryGroup) => accessGroupNames(entryGroup)));
}
/** Normalizes direct entries while preserving access-group tokens for later expansion. */
export async function normalizeEffectiveEntries(params: {
adapter: ChannelIngressAdapter;
accountId: string;
@@ -45,6 +47,7 @@ export async function normalizeEffectiveEntries(params: {
]);
}
/** Resolves dynamic access-group facts before the state builder expands static sender groups. */
export async function resolveRuntimeAccessGroupMembershipFacts(params: {
input: ResolveChannelMessageIngressParams;
channelId: ChannelIngressChannelId;

View File

@@ -95,6 +95,7 @@ function adapterEntry(params: {
};
}
/** Creates the normalization/matching adapter used by the ingress decision engine. */
export function createIdentityAdapter(
identity: ChannelIngressIdentityDescriptor,
): ChannelIngressAdapter {
@@ -144,6 +145,7 @@ export function createIdentityAdapter(
const matchedEntryIds = entries
.filter((entry) => {
const fallback = entry.value === "*" || subjectKeys.has(identityMatchKey(entry));
// Custom identity hooks may widen or narrow matches; undefined preserves default matching.
return identity.matchEntry?.({ subject, entry, context }) ?? fallback;
})
.map((entry) => entry.opaqueEntryId);
@@ -155,6 +157,7 @@ export function createIdentityAdapter(
};
}
/** Converts raw channel sender ids into redaction-aware subject identifiers. */
export function createIdentitySubject(
identity: ChannelIngressIdentityDescriptor,
input: ChannelIngressIdentitySubjectInput,

View File

@@ -463,6 +463,7 @@ function projectRouteAccess(params: {
const senderBlock = params.ingress.graph.gates.find(
(entry) => entry.phase === "sender" && entry.effect === "block-dispatch",
);
// Route sender replacement moves the route's user-facing reason onto the sender gate.
if (routeSenderReplacement && senderBlock) {
return {
allowed: false,
@@ -615,6 +616,7 @@ export async function resolveChannelMessageIngress(
const rawGroupAllowFrom = normalizeStringEntries(params.groupAllowFrom ?? []);
const normalizeEffective = (entries: readonly (string | number)[], context: "dm" | "group") =>
normalizeEffectiveEntries({ adapter, accountId: params.accountId, entries, context });
// Keep raw allowlists for redacted state/graph evidence while normalized copies drive matching.
const [normalizedAllowFrom, normalizedStoreAllowFrom, normalizedGroupAllowFrom] =
await Promise.all([
normalizeEffective(rawAllowFrom, "dm"),

View File

@@ -34,6 +34,7 @@ function senderGate(params: {
};
}
/** Evaluates direct-message sender policy against configured and pairing-store allowlists. */
export function senderGateForDirect(params: {
state: ChannelIngressState;
policy: ChannelIngressPolicyInput;
@@ -70,6 +71,7 @@ export function senderGateForDirect(params: {
return block("dm_policy_disabled");
}
if (params.policy.dmPolicy === "open") {
// Open DMs still require an explicit wildcard or match; they skip pairing-store fallback only.
if (dm.hasWildcard) {
return allow("dm_policy_open");
}
@@ -103,6 +105,7 @@ export function senderGateForDirect(params: {
return block(reasonCode);
}
/** Evaluates group/channel sender policy after route sender overrides are applied. */
export function senderGateForGroup(params: {
state: ChannelIngressState;
policy: ChannelIngressPolicyInput;
@@ -146,6 +149,7 @@ export function senderGateForGroup(params: {
return block(allowlistFailureReason(group) ?? "group_policy_not_allowlisted");
}
/** Converts sender blocks into ignored gates for event modes that authorize elsewhere. */
export function applyEventAuthModeToSenderGate(params: {
state: ChannelIngressState;
senderGate: AccessGraphGate;

View File

@@ -317,6 +317,7 @@ async function resolveIngressAllowlist(params: {
async function resolveRouteFacts(
input: ChannelIngressStateInput,
): Promise<ResolvedRouteGateFacts[]> {
// Deterministic route order keeps the access graph stable across config object iteration.
const routeFacts = [...(input.routeFacts ?? [])].toSorted(
(left, right) => left.precedence - right.precedence || left.id.localeCompare(right.id),
);

View File

@@ -10,6 +10,8 @@ function hasMediaPayload(
if (payload.mediaUrl?.trim()) {
return true;
}
// Multi-media payloads may contain empty optional slots; only non-empty URLs require the media
// durable-final capability.
return (
Array.isArray(payload.mediaUrls) &&
payload.mediaUrls.some((url) => typeof url === "string" && url.trim().length > 0)
@@ -40,6 +42,8 @@ export function deriveDurableFinalDeliveryRequirements(
);
setRequired(requirements, "thread", params.threadId != null);
setRequired(requirements, "silent", params.silent);
// Sending hooks are required by default because durable final delivery must preserve adapter
// lifecycle hooks unless the caller explicitly opted out.
setRequired(requirements, "messageSendingHooks", params.messageSendingHooks !== false);
setRequired(requirements, "payload", params.payloadTransport);
setRequired(requirements, "batch", params.batch);

View File

@@ -21,7 +21,9 @@ export type DurableFinalCapabilityProofMap = Partial<
>;
export type DurableFinalCapabilityProofResult = {
/** Capability checked in canonical capability order. */
capability: DurableFinalDeliveryCapability;
/** Whether the capability was declared and proved by the adapter test. */
status: "verified" | "not_declared";
};
@@ -44,20 +46,27 @@ export type ChannelMessageReceiveAckPolicyProofMap = Partial<
>;
export type LivePreviewFinalizerCapabilityProofResult = {
/** Finalizer capability checked in canonical capability order. */
capability: LivePreviewFinalizerCapability;
/** Whether the capability was declared and proved by the adapter test. */
status: "verified" | "not_declared";
};
export type ChannelMessageLiveCapabilityProofResult = {
/** Live-message capability checked in canonical capability order. */
capability: ChannelMessageLiveCapability;
/** Whether the capability was declared and proved by the adapter test. */
status: "verified" | "not_declared";
};
export type ChannelMessageReceiveAckPolicyProofResult = {
/** Receive acknowledgement policy checked in canonical policy order. */
policy: ChannelMessageReceiveAckPolicy;
/** Whether the policy was declared and proved by the adapter test. */
status: "verified" | "not_declared";
};
/** List declared durable-final capabilities in canonical order. */
export function listDeclaredDurableFinalCapabilities(
capabilities: DurableFinalDeliveryRequirementMap | undefined,
): DurableFinalDeliveryCapability[] {
@@ -66,6 +75,7 @@ export function listDeclaredDurableFinalCapabilities(
);
}
/** List declared live-preview finalizer capabilities in canonical order. */
export function listDeclaredLivePreviewFinalizerCapabilities(
capabilities: LivePreviewFinalizerCapabilityMap | undefined,
): LivePreviewFinalizerCapability[] {
@@ -74,12 +84,14 @@ export function listDeclaredLivePreviewFinalizerCapabilities(
);
}
/** List declared live-message capabilities in canonical order. */
export function listDeclaredChannelMessageLiveCapabilities(
capabilities: Partial<Record<ChannelMessageLiveCapability, boolean>> | undefined,
): ChannelMessageLiveCapability[] {
return channelMessageLiveCapabilities.filter((capability) => capabilities?.[capability] === true);
}
/** List receive acknowledgement policies, falling back from supported policies to the default. */
export function listDeclaredReceiveAckPolicies(
receive: ChannelMessageAdapterShape["receive"] | undefined,
): ChannelMessageReceiveAckPolicy[] {
@@ -91,6 +103,7 @@ export function listDeclaredReceiveAckPolicies(
return channelMessageReceiveAckPolicies.filter((policy) => declared.includes(policy));
}
/** Run one proof for every declared durable-final capability and fail on missing proofs. */
export async function verifyDurableFinalCapabilityProofs(params: {
adapterName: string;
capabilities?: DurableFinalDeliveryRequirementMap;
@@ -114,6 +127,7 @@ export async function verifyDurableFinalCapabilityProofs(params: {
return results;
}
/** Run one proof for every declared live-preview finalizer capability. */
export async function verifyLivePreviewFinalizerCapabilityProofs(params: {
adapterName: string;
capabilities?: LivePreviewFinalizerCapabilityMap;
@@ -137,6 +151,7 @@ export async function verifyLivePreviewFinalizerCapabilityProofs(params: {
return results;
}
/** Run one proof for every declared live-message capability. */
export async function verifyChannelMessageLiveCapabilityProofs(params: {
adapterName: string;
capabilities?: Partial<Record<ChannelMessageLiveCapability, boolean>>;
@@ -160,6 +175,7 @@ export async function verifyChannelMessageLiveCapabilityProofs(params: {
return results;
}
/** Run one proof for every declared receive acknowledgement policy. */
export async function verifyChannelMessageReceiveAckPolicyProofs(params: {
adapterName: string;
receive?: ChannelMessageAdapterShape["receive"];
@@ -184,6 +200,7 @@ export async function verifyChannelMessageReceiveAckPolicyProofs(params: {
return results;
}
/** Verify durable-final capabilities declared on a full channel message adapter. */
export async function verifyChannelMessageAdapterCapabilityProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "durableFinal">;
@@ -196,6 +213,7 @@ export async function verifyChannelMessageAdapterCapabilityProofs(params: {
});
}
/** Verify receive acknowledgement policies declared on a full channel message adapter. */
export async function verifyChannelMessageReceiveAckPolicyAdapterProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "receive">;
@@ -208,6 +226,7 @@ export async function verifyChannelMessageReceiveAckPolicyAdapterProofs(params:
});
}
/** Verify live-preview finalizer capabilities declared on a full channel message adapter. */
export async function verifyChannelMessageLiveFinalizerProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "live">;
@@ -220,6 +239,7 @@ export async function verifyChannelMessageLiveFinalizerProofs(params: {
});
}
/** Verify live-message capabilities declared on a full channel message adapter. */
export async function verifyChannelMessageLiveCapabilityAdapterProofs(params: {
adapterName: string;
adapter: Pick<ChannelMessageAdapterShape, "live">;

View File

@@ -67,17 +67,22 @@ export type DurableInboundReceiveReleaseOptions = {
/** Durable receive journal facade used by channel receive pipelines. */
export type DurableInboundReceiveJournal<TPayload, TMetadata, TCompletedMetadata> = {
/** Records a platform event unless a pending/completed duplicate already exists. */
accept(
id: string,
payload: TPayload,
options?: DurableInboundReceiveAcceptOptions<TMetadata>,
): Promise<DurableInboundReceiveAcceptResult<TPayload, TMetadata, TCompletedMetadata>>;
/** Returns pending records in deterministic receive-time order. */
pending(): Promise<Array<DurableInboundReceivePendingRecord<TPayload, TMetadata>>>;
/** Moves an inbound event from pending to completed duplicate-suppression state. */
complete(
id: string,
options?: DurableInboundReceiveCompleteOptions<TCompletedMetadata>,
): Promise<void>;
/** Requeues a pending event after a failed dispatch attempt. */
release(id: string, options?: DurableInboundReceiveReleaseOptions): Promise<boolean>;
/** Deletes pending state without creating a completed tombstone. */
deletePending(id: string): Promise<boolean>;
};
@@ -158,6 +163,8 @@ export function createDurableInboundReceiveJournal<
return { kind: "pending", duplicate: true, record: pending };
}
// A delete/complete race can make the pending lookup miss after registerIfAbsent lost; check
// completion before retrying so a completed duplicate never re-enters pending state.
const completedAfterPendingRace = await options.completedStore.lookup(key);
if (completedAfterPendingRace) {
return { kind: "completed", duplicate: true, record: completedAfterPendingRace };
@@ -182,6 +189,8 @@ export function createDurableInboundReceiveJournal<
const entries = await options.pendingStore.entries();
const records: Array<DurableInboundReceivePendingRecord<TPayload, TMetadata>> = [];
for (const entry of entries) {
// Tombstones win over stale pending entries; clean them up while reading to keep callers
// from dispatching a duplicate event that has already completed.
if (await options.completedStore.lookup(entry.key)) {
await options.pendingStore.delete(entry.key);
continue;

View File

@@ -1,8 +1,3 @@
/**
* Shared inbound reply dispatch helpers for channel message adapters and
* deprecated SDK compatibility facades.
*/
import { withReplyDispatcher } from "../../auto-reply/dispatch.js";
import type { GetReplyOptions } from "../../auto-reply/get-reply-options.types.js";
import {
@@ -57,12 +52,16 @@ type ReplyOptionsWithoutModelSelected = Omit<
type RecordInboundSessionFn = typeof import("../session.js").recordInboundSession;
type ReplyDispatchFromConfigOptions = Omit<GetReplyOptions, "onBlockReply">;
/** Parameters for running a raw inbound channel event through the shared turn pipeline. */
export type ChannelInboundEventRunnerParams<
TRaw,
TDispatchResult = DispatchFromConfigResult,
> = RunChannelTurnParams<TRaw, TDispatchResult>;
/** Prepared turn shape kept for legacy inbound-reply naming. */
export type PreparedInboundReply<TDispatchResult> = PreparedChannelTurn<TDispatchResult>;
/** Assembled dispatch context kept for legacy inbound-reply naming. */
export type AssembledInboundReply = AssembledChannelTurn;
/** Turn result shape kept for legacy inbound-reply naming. */
export type InboundReplyDispatchResult<TDispatchResult> = ChannelTurnResult<TDispatchResult>;
/** Run an already prepared inbound reply through shared session-record + dispatch ordering. */
@@ -148,6 +147,8 @@ export async function dispatchReplyFromConfigWithSettledDispatcher(params: {
return await withReplyDispatcher({
dispatcher: params.dispatcher,
onSettled: params.onSettled,
// withReplyDispatcher owns the finally path so streamed/block dispatchers
// release typing, buffers, and channel resources even when dispatch throws.
run: () =>
dispatchReplyFromConfig({
ctx: params.ctxPayload,
@@ -197,19 +198,33 @@ export function buildInboundReplyDispatchBase(params: {
type BuildInboundReplyDispatchBaseParams = Parameters<typeof buildInboundReplyDispatchBase>[0];
type RecordChannelMessageReplyDispatchParams = {
/** Config used to resolve agent/session/reply settings for the inbound turn. */
cfg: OpenClawConfig;
/** Channel id that owns the inbound reply turn. */
channel: string;
/** Optional account scope for multi-account channel adapters. */
accountId?: string;
/** Agent selected by route resolution before dispatch starts. */
agentId: string;
/** Stable session key used for inbound session history. */
routeSessionKey: string;
/** Store path used by the reply dispatcher for session state. */
storePath: string;
/** Finalized inbound message context passed to prompt templating. */
ctxPayload: FinalizedMsgContext;
/** Session recorder that must run before reply dispatch. */
recordInboundSession: RecordInboundSessionFn;
/** Buffered reply dispatcher used to produce tool/block/final reply deliveries. */
dispatchReplyWithBufferedBlockDispatcher: DispatchReplyWithBufferedBlockDispatcher;
/** Legacy outbound delivery callback used when durable message delivery is unavailable. */
deliver: (payload: OutboundReplyPayload) => Promise<void>;
/** Durable delivery options, or false to force the legacy deliver callback. */
durable?: false | DurableInboundReplyDeliveryOptions;
/** Error sink for session-record failures that should not skip dispatch. */
onRecordError: (err: unknown) => void;
/** Error sink for reply delivery failures, tagged by reply kind. */
onDispatchError: (err: unknown, info: { kind: string }) => void;
/** Reply options forwarded without block-dispatcher/model-selection overrides. */
replyOptions?: ReplyOptionsWithoutModelSelected;
};
@@ -276,11 +291,11 @@ export async function recordChannelMessageReplyDispatch(
dispatchReplyWithBufferedBlockDispatcher: params.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
preparePayload: (payload) =>
payload && typeof payload === "object"
? normalizeOutboundReplyPayload(payload)
: {},
payload && typeof payload === "object" ? normalizeOutboundReplyPayload(payload) : {},
deliver: async (payload, info) => {
if (params.durable) {
// Durable delivery owns normalized message lifecycle results; fall
// back only when the adapter reports that this payload was unhandled.
const durable = await deliverInboundReplyWithMessageSendContext({
cfg: params.cfg,
channel: params.channel,
@@ -296,6 +311,8 @@ export async function recordChannelMessageReplyDispatch(
return durable.delivery;
}
}
// Compatibility callers still own legacy delivery when durable routing
// is disabled or cannot handle this specific normalized payload.
return await params.deliver(payload as OutboundReplyPayload);
},
onError: params.onDispatchError,

View File

@@ -114,6 +114,7 @@ export type ChannelIngressQueueEnqueueResult<TPayload, TMetadata, TCompletedMeta
/** Durable FIFO-ish ingress queue with claims, duplicate detection, and retention pruning. */
export type ChannelIngressQueue<TPayload, TMetadata = unknown, TCompletedMetadata = unknown> = {
/** Accepts a platform event id once and reports existing pending/claimed/tombstone duplicates. */
enqueue(
id: string,
payload: TPayload,
@@ -123,38 +124,47 @@ export type ChannelIngressQueue<TPayload, TMetadata = unknown, TCompletedMetadat
laneKey?: string;
},
): Promise<ChannelIngressQueueEnqueueResult<TPayload, TMetadata, TCompletedMetadata>>;
/** Lists unclaimed pending events in receive order unless id ordering is requested. */
listPending(options?: {
limit?: number | "all";
orderBy?: "received" | "id";
}): Promise<Array<ChannelIngressQueueRecord<TPayload, TMetadata>>>;
/** Lists currently claimed events for recovery and worker diagnostics. */
listClaims(): Promise<Array<ChannelIngressQueueClaim<TPayload, TMetadata>>>;
/** Claims the next available event while optionally skipping lane keys already in flight. */
claimNext(options?: {
ownerId?: string;
blockedLaneKeys?: Iterable<string>;
staleMs?: number;
}): Promise<ChannelIngressQueueClaim<TPayload, TMetadata> | null>;
/** Claims one pending event by id for targeted replay or repair work. */
claim(
id: string,
options?: { ownerId?: string },
): Promise<ChannelIngressQueueClaim<TPayload, TMetadata> | null>;
/** Converts a pending/claimed event into a completed tombstone for duplicate suppression. */
complete(
idOrClaim: string | ChannelIngressQueueClaimRef,
options?: { metadata?: TCompletedMetadata; completedAt?: number },
): Promise<boolean>;
/** Releases a pending/claimed event for retry and records attempt/error metadata. */
release(
idOrClaim: string | ChannelIngressQueueClaimRef,
options?: { lastError?: string; releasedAt?: number },
): Promise<boolean>;
/** Converts a pending/claimed event into a failed tombstone for diagnostics and dedupe. */
fail(
idOrClaim: string | ChannelIngressQueueClaimRef,
options: { reason: string; message?: string; failedAt?: number },
): Promise<boolean>;
/** Deletes a pending/claimed event without leaving a duplicate-suppression tombstone. */
delete(
idOrClaim:
| string
| ChannelIngressQueueRecord<TPayload, TMetadata>
| ChannelIngressQueueClaimRef,
): Promise<boolean>;
/** Releases stale claims after an optional caller veto for live worker ownership checks. */
recoverStaleClaims(options?: {
staleMs?: number;
now?: number;
@@ -162,6 +172,7 @@ export type ChannelIngressQueue<TPayload, TMetadata = unknown, TCompletedMetadat
claim: ChannelIngressQueueClaim<TPayload, TMetadata>,
) => boolean | Promise<boolean>;
}): Promise<number>;
/** Removes expired or over-limit pending/completed/failed rows while preserving protected ids. */
prune(options?: ChannelIngressQueuePruneOptions): Promise<number>;
};
@@ -281,6 +292,8 @@ function idFrom(idOrRecord: string | { id: string }): string {
function claimTokenFrom(
idOrClaim: string | { id: string; claim?: { token: string } },
): string | null {
// Mutations on claimed rows must carry the claim token so stale workers cannot complete or drop
// events after another worker recovered and claimed the same id.
return typeof idOrClaim === "string" ? null : (idOrClaim.claim?.token ?? null);
}
@@ -786,6 +799,8 @@ export function createChannelIngressQueue<
const batchSize = 500;
const protectedSet = new Set(protectIds);
while (true) {
// Keep the newest rows by updated time; delete overflow in bounded batches so a large
// queue cannot build an unbounded SQL parameter list.
const rowsToDelete = executeSqliteQuerySync(
tx.db,
kysely

View File

@@ -3,10 +3,15 @@ export type { LiveMessagePhase, LiveMessageState } from "./types.js";
/** Mutable draft preview handle used before a live message is finalized or discarded. */
export type LivePreviewFinalizerDraft<TId> = {
/** Flush pending preview updates before reading or editing the draft id. */
flush: () => Promise<void>;
/** Return the provider id for the current draft preview, if one exists. */
id: () => TId | undefined;
/** Prevent later preview edits before finalizing in place. */
seal?: () => Promise<void>;
/** Drop queued preview work while keeping the visible draft available for fallback cleanup. */
discardPending?: () => Promise<void>;
/** Remove all local/provider draft preview state after final delivery. */
clear: () => Promise<void>;
};
@@ -26,17 +31,23 @@ export type LivePreviewFinalizerResult<TPayload> = {
/** Adapter contract for channels that can edit a draft preview into the final message. */
export type FinalizableLivePreviewAdapter<TPayload, TId, TEdit> = {
draft?: LivePreviewFinalizerDraft<TId>;
/** Convert the final payload into a provider-native edit, or return undefined to fall back. */
buildFinalEdit: (payload: TPayload) => TEdit | undefined;
/** Apply the final edit to the draft preview id. */
editFinal: (id: TId, edit: TEdit) => Promise<void>;
/** Map draft ids to the final platform id when the provider changes ids after edit. */
resolveFinalizedId?: (id: TId, edit: TEdit) => TId | undefined;
/** Build the receipt used after finalizing a preview in place. */
createPreviewReceipt?: (id: TId, edit: TEdit) => MessageReceipt;
onPreviewFinalized?: (
id: TId,
receipt: MessageReceipt,
liveState: LiveMessageState<TPayload>,
) => Promise<void> | void;
/** Extract media or other payload pieces that still need normal delivery after final edit. */
buildSupplementalPayload?: (payload: TPayload) => TPayload | undefined;
deliverSupplemental?: (payload: TPayload) => Promise<boolean | void>;
/** Decide whether an ambiguous preview edit error should fall back or retain the preview. */
handlePreviewEditError?: (params: {
error: unknown;
id: TId;
@@ -202,6 +213,7 @@ export async function deliverFinalizableLivePreview<TPayload, TId, TEdit>(params
}
if (params.draft.discardPending) {
// Final edit was impossible; discard pending preview work before sending a normal final reply.
await params.draft.discardPending();
} else {
await params.draft.clear();

View File

@@ -19,26 +19,33 @@ const defaultManualReceiveAdapter = {
supportedAckPolicies: ["manual"],
} as const satisfies ChannelMessageReceiveAdapterShape;
/** Send result accepted from legacy outbound bridge methods before receipt normalization. */
/** Legacy send result accepted by outbound bridge methods before receipt normalization. */
export type ChannelMessageOutboundBridgeResult = MessageReceiptSourceResult & {
/** Already-normalized receipt from adapters that can describe multipart sends themselves. */
receipt?: MessageReceipt;
/** Adapter-level id retained for older callers that do not return a full receipt. */
messageId?: string;
};
/** Legacy outbound adapter shape bridged into the channel message adapter contract. */
export type ChannelMessageOutboundBridgeAdapter<TConfig = unknown> = {
/** Durable final-send capabilities declared by older outbound implementations. */
deliveryCapabilities?: {
durableFinal?: DurableFinalDeliveryRequirementMap;
};
/** Text-only send hook used when the channel exposes a narrow text API. */
sendText?: (
ctx: ChannelMessageSendTextContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
/** Media send hook used for file/image/audio sends with optional caption text. */
sendMedia?: (
ctx: ChannelMessageSendMediaContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
/** Structured payload hook used by channels that consume rich reply payloads directly. */
sendPayload?: (
ctx: ChannelMessageSendPayloadContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
/** Poll send hook used when the platform has a native poll endpoint. */
sendPoll?: (
ctx: ChannelMessageSendPollContext<TConfig>,
) => Promise<ChannelMessageOutboundBridgeResult>;
@@ -46,14 +53,21 @@ export type ChannelMessageOutboundBridgeAdapter<TConfig = unknown> = {
/** Options for building a message adapter from legacy outbound send functions. */
export type CreateChannelMessageAdapterFromOutboundParams<TConfig = unknown> = {
/** Stable adapter id surfaced through channel message capability listings. */
id?: string;
/** Legacy outbound implementation to wrap. */
outbound: ChannelMessageOutboundBridgeAdapter<TConfig>;
/** Capability override when wrapper ownership, not legacy outbound, declares guarantees. */
capabilities?: DurableFinalDeliveryRequirementMap;
/** Optional live-preview adapter metadata to preserve on the wrapped shape. */
live?: ChannelMessageLiveAdapterShape;
/** Optional receive adapter metadata; defaults to manual ack ownership for legacy sends. */
receive?: ChannelMessageReceiveAdapterShape;
};
function resolveResultMessageId(result: ChannelMessageOutboundBridgeResult): string | undefined {
// Prefer explicit and normalized receipt ids before provider-specific ids so follow-up edits
// target the same primary platform message that receipt normalization selected.
return (
result.messageId ??
result.receipt?.primaryPlatformMessageId ??
@@ -76,6 +90,8 @@ function toMessageSendResult(
replyToId?: string | null;
},
): ChannelMessageSendResult {
// Poll APIs often return card-like receipts from older senders; normalize the part kind so
// durable capability checks and recovery classify the message by the API that sent it.
const receipt = result.receipt
? params.normalizeReceiptKind
? {
@@ -102,6 +118,8 @@ function toMessageSendResult(
function resolvePayloadReceiptKind(
ctx: ChannelMessageSendPayloadContext<unknown>,
): MessageReceiptPartKind {
// Structured payload sends can collapse multiple content shapes into one hook; preserve the
// most specific durable-recovery kind rather than treating every payload as a generic card.
if (
ctx.payload.audioAsVoice &&
(ctx.mediaUrl || ctx.payload.mediaUrl || ctx.payload.mediaUrls?.length)

View File

@@ -81,6 +81,7 @@ export function createMessageReceiptFromOutboundResults(params: {
const platformMessageIds: string[] = [];
for (const result of params.results) {
if (hasNestedReceiptData(result.receipt)) {
// Keep adapter-supplied id order before adding part ids; downstream edit/delete uses the first id.
appendUnique(platformMessageIds, result.receipt.primaryPlatformMessageId);
for (const platformMessageId of result.receipt.platformMessageIds) {
appendUnique(platformMessageIds, platformMessageId);

View File

@@ -11,18 +11,31 @@ export type MessageAckState = "pending" | "acked" | "nacked";
/** Mutable receive context passed through durable inbound message processing. */
export type MessageReceiveContext<TMessage = unknown> = {
/** Provider-native inbound message id. */
id: string;
/** Channel id that received the inbound message. */
channel: string;
/** Optional account scope for multi-account channels. */
accountId?: string;
/** Provider-native or normalized inbound message payload. */
message: TMessage;
/** Policy controlling when the message should be acknowledged. */
ackPolicy: MessageAckPolicy;
/** Current acknowledgement state. */
ackState: MessageAckState;
/** Timestamp recorded when ack succeeds. */
ackedAt?: number;
/** Human-readable nack error when acknowledgement fails. */
nackErrorMessage?: string;
/** Timestamp when core accepted the inbound message for processing. */
receivedAt: number;
/** Cancellation signal for downstream receive processing. */
signal: AbortSignal;
/** Returns whether the current policy wants an ack after the supplied pipeline stage. */
shouldAckAfter(stage: MessageAckStage): boolean;
/** Marks the message acknowledged and runs the adapter ack hook at most once. */
ack(): Promise<void>;
/** Marks the message negatively acknowledged and records the normalized failure message. */
nack(error: unknown): Promise<void>;
};
@@ -33,6 +46,8 @@ export function shouldAckMessageAfterStage(
policy: MessageAckPolicy,
stage: MessageAckStage,
): boolean {
// Ack stages intentionally map one-to-one to policies; "manual" never auto-acks so channel
// adapters can own platform-specific acknowledgement timing themselves.
switch (policy) {
case "after_receive_record":
return stage === "receive_record";

View File

@@ -16,6 +16,7 @@ function collectMediaUrls(payload: ReplyPayload): string[] {
.filter((url): url is string => Boolean(url));
}
/** Builds the replayable content-shape summary for one rendered reply payload. */
function createRenderedMessageBatchPlanItem(
payload: ReplyPayload,
index: number,

View File

@@ -45,10 +45,13 @@ export type ChannelReplyPipeline = ReplyPrefixOptions & {
export type CreateChannelReplyPipelineParams = {
cfg: Parameters<typeof createReplyPrefixOptions>[0]["cfg"];
agentId: string;
/** Channel id used for prefix policy and lazy plugin reply transforms. */
channel?: string;
/** Account id passed to channel-owned reply transforms. */
accountId?: string;
typing?: CreateTypingCallbacksParams;
typingCallbacks?: TypingCallbacks;
/** Caller override that runs instead of the channel plugin transform. */
transformReplyPayload?: (payload: ReplyPayload) => ReplyPayload | null;
};
@@ -62,7 +65,8 @@ export function createChannelReplyPipeline(
let plugin: ReturnType<typeof getLoadedChannelPluginForRead> | undefined;
let pluginTransformResolved = false;
const resolvePluginTransform = () => {
// Load the channel plugin lazily so reply-pipeline construction stays cheap for hot turn paths.
// Load the channel plugin lazily and at most once so reply-pipeline
// construction stays cheap for hot turn paths that never send a reply.
if (pluginTransformResolved) {
return plugin?.messaging?.transformReplyPayload;
}
@@ -73,13 +77,25 @@ export function createChannelReplyPipeline(
const transformReplyPayload = params.transformReplyPayload
? params.transformReplyPayload
: channelId
? (payload: ReplyPayload) =>
resolvePluginTransform()?.({
payload,
cfg: params.cfg,
accountId: params.accountId,
}) ?? payload
? (payload: ReplyPayload) => {
// Channel-owned transforms run after prefix/typing setup, but an
// explicit caller transform above bypasses registry lookup entirely.
return (
resolvePluginTransform()?.({
payload,
cfg: params.cfg,
accountId: params.accountId,
}) ?? payload
);
}
: undefined;
const typingCallbacks = params.typingCallbacks
? params.typingCallbacks
: params.typing
? createTypingCallbacks(params.typing)
: undefined;
// Preserve prebuilt callbacks for channels with custom lifecycle hooks;
// otherwise synthesize callbacks only when typing config is provided.
return {
...createReplyPrefixOptions({
cfg: params.cfg,
@@ -88,10 +104,6 @@ export function createChannelReplyPipeline(
accountId: params.accountId,
}),
...(transformReplyPayload ? { transformReplyPayload } : {}),
...(params.typingCallbacks
? { typingCallbacks: params.typingCallbacks }
: params.typing
? { typingCallbacks: createTypingCallbacks(params.typing) }
: {}),
...(typingCallbacks ? { typingCallbacks } : {}),
};
}

View File

@@ -30,11 +30,15 @@ export type DurableMessageBatchSendParams = Omit<
DeliverOutboundPayloadsParams,
"abortSignal" | "onDeliveryIntent" | "payloads" | "queuePolicy"
> & {
/** Reply payloads to render and send as one logical durable batch. */
payloads: ReplyPayload[];
/** Retry attempt number surfaced through the send context. */
attempt?: number;
/** Preferred cancellation signal for durable delivery. */
signal?: AbortSignal;
/** @deprecated Use `signal`. */
abortSignal?: AbortSignal;
/** Receipt from a previous preview/send attempt, when retrying. */
previousReceipt?: MessageReceipt;
};
@@ -46,13 +50,17 @@ export type DurableMessageFailureStage = "platform_send" | "queue" | "unknown";
export type DurableMessagePayloadDeliveryOutcome =
| {
/** Payload index within the rendered batch. */
index: number;
status: "sent";
/** Raw platform results produced for this payload. */
results: OutboundDeliveryResult[];
}
| {
/** Payload index within the rendered batch. */
index: number;
status: "suppressed";
/** Why no visible platform message was sent. */
reason: DurableMessageSuppressionReason;
hookEffect?: {
cancelReason?: string;
@@ -60,10 +68,13 @@ export type DurableMessagePayloadDeliveryOutcome =
};
}
| {
/** Payload index within the rendered batch. */
index: number;
status: "failed";
error: unknown;
/** True when the platform may already have accepted a prior payload. */
sentBeforeError: boolean;
/** Phase where delivery failed or became ambiguous. */
stage: DurableMessageFailureStage;
};
@@ -131,6 +142,7 @@ function toDurablePayloadOutcomes(
export type DurableMessageSendContextParams = DurableMessageBatchSendParams & {
durability?: Exclude<MessageDurabilityPolicy, "disabled">;
/** Live preview state carried across render/send/edit/commit hooks. */
preview?: LiveMessageState<ReplyPayload>;
onPreviewUpdate?: (
rendered: RenderedMessageBatch<ReplyPayload>,
@@ -326,6 +338,7 @@ export async function withDurableMessageSendContext<T>(
const result = await run(ctx);
return result;
} catch (error: unknown) {
// Cleanup failures are logged inside ctx.fail so callers still observe the original send error.
await ctx.fail(error);
throw error;
}

View File

@@ -10,10 +10,15 @@ export type DurableMessageSendState =
/** Recovery record for one durable outbound message intent. */
export type DurableMessageStateRecord = {
/** Replayable outbound intent captured before or during platform send. */
intent: DurableMessageSendIntent;
/** Current recovery classification for this durable send. */
state: DurableMessageSendState;
/** Platform receipt when the send is known to have completed. */
receipt?: MessageReceipt;
/** Last state transition time in milliseconds. */
updatedAt: number;
/** Human-readable failure summary for operator-visible status. */
errorMessage?: string;
};

View File

@@ -43,15 +43,25 @@ export type DurableFinalDeliveryPayloadShape = {
/** Raw platform result shape normalized into a message receipt. */
export type MessageReceiptSourceResult = {
/** Provider/channel id that produced the platform result. */
channel?: string;
/** Generic platform message id returned by most send APIs. */
messageId?: string;
/** Chat-scoped id used by some channel APIs as the sent message id. */
chatId?: string;
/** Channel-scoped id returned by workspace-style APIs. */
channelId?: string;
/** Room-scoped id returned by room-based providers. */
roomId?: string;
/** Conversation-scoped id returned by conversation-first providers. */
conversationId?: string;
/** WhatsApp/JID-style destination id used as a fallback receipt key. */
toJid?: string;
/** Poll id returned when the send created a platform poll. */
pollId?: string;
/** Platform send timestamp when the adapter exposes it. */
timestamp?: number;
/** Provider-native metadata retained for reconciliation/debugging. */
meta?: Record<string, unknown>;
};
@@ -67,24 +77,39 @@ export type MessageReceiptPartKind =
/** One platform message produced by a logical outbound send. */
export type MessageReceiptPart = {
/** Platform message id for this concrete sent part. */
platformMessageId: string;
/** Logical content kind that produced this part. */
kind: MessageReceiptPartKind;
/** Stable order within the logical send. */
index: number;
/** Thread/topic id used by the platform for this part. */
threadId?: string;
/** Platform message id this part replied to. */
replyToId?: string;
/** Raw adapter result retained when built from legacy send output. */
raw?: MessageReceiptSourceResult;
};
/** Normalized receipt for all platform messages that make up a logical send. */
export type MessageReceipt = {
/** Preferred platform id for edits/deletes when a logical send has multiple parts. */
primaryPlatformMessageId?: string;
/** Unique platform ids in send order. */
platformMessageIds: string[];
/** Per-part receipts for multipart sends. */
parts: MessageReceiptPart[];
/** Thread/topic id shared by the logical send when available. */
threadId?: string;
/** Reply target shared by the logical send when available. */
replyToId?: string;
/** Provider token required to edit the sent message. */
editToken?: string;
/** Provider token required to delete the sent message. */
deleteToken?: string;
/** Millisecond timestamp when core considers the logical send complete. */
sentAt: number;
/** Raw adapter results used to construct this normalized receipt. */
raw?: readonly MessageReceiptSourceResult[];
};
@@ -410,24 +435,40 @@ export type DurableFinalRequirementExtras = DurableFinalDeliveryRequirementMap;
/** Inputs used to derive durable final-delivery requirements for a planned send. */
export type DeriveDurableFinalDeliveryRequirementsParams = {
payload: DurableFinalDeliveryPayloadShape;
/** Reply target means the adapter needs reply-to durability support. */
replyToId?: string | null;
/** Thread target means the adapter needs thread durability support. */
threadId?: string | number | null;
/** Silent sends require adapters to declare silent final-delivery support. */
silent?: boolean;
/** Whether lifecycle hooks around sends must be preserved by durable delivery. */
messageSendingHooks?: boolean;
/** Whether the planned send uses the structured payload adapter path. */
payloadTransport?: boolean;
/** Whether multiple rendered payloads must be delivered as one durable logical batch. */
batch?: boolean;
/** Whether unknown platform-send outcomes require adapter reconciliation. */
reconcileUnknownSend?: boolean;
/** Whether post-send success hooks must run before the send is considered durable. */
afterSendSuccess?: boolean;
/** Whether commit hooks must run before the final receipt is trusted. */
afterCommit?: boolean;
/** Caller-supplied capabilities that extend the built-in derivation rules. */
extraCapabilities?: DurableFinalRequirementExtras;
};
/** Stable intent record for a durable outbound message send. */
export type DurableMessageSendIntent<TPayload = unknown> = {
/** Queue-stable id for this logical outbound send. */
id: string;
/** Channel id that owns the outbound send. */
channel: string;
/** Provider-native destination target. */
to: string;
/** Optional account scope used by multi-account channels. */
accountId?: string;
/** Durable policy selected after disabled sends have been filtered out. */
durability: Exclude<MessageDurabilityPolicy, "disabled">;
/** Last rendered payload batch, retained for retry/reconciliation. */
renderedBatch?: RenderedMessageBatch<TPayload>;
};

View File

@@ -46,6 +46,8 @@ function resolveProviderEntry(
): Record<string, string> | undefined {
const normalized =
normalizeMessageChannel(channel) ?? normalizeOptionalLowercaseString(channel) ?? "";
// Accept both canonical channel ids and legacy/case-varied config keys so
// existing modelByChannel entries survive channel id normalization changes.
return (
modelByChannel?.[normalized] ??
modelByChannel?.[
@@ -70,6 +72,9 @@ function buildChannelCandidates(
const groupId = normalizeOptionalString(params.groupId);
const rawParentConversation = parseRawSessionConversationRef(params.parentSessionKey);
const channelPlugin = normalizedChannel ? getChannelPlugin(normalizedChannel) : undefined;
// Some channels encode parent conversations differently from generic session
// keys; let the loaded plugin add candidates before falling back to bundled
// parsing so per-channel thread model overrides still match.
const parentOverrideFallbacks =
channelPlugin?.conversationBindings?.buildModelOverrideParentCandidates?.({
parentConversationId: rawParentConversation?.rawId,
@@ -120,6 +125,8 @@ function buildGenericParentOverrideCandidates(sessionKey: string | null | undefi
return [];
}
const { baseSessionKey, threadId } = parseThreadSessionSuffix(raw.rawId);
// Thread child sessions inherit from their base session key; non-thread
// parents keep the raw conversation id as the direct override candidate.
return buildChannelKeyCandidates(threadId ? baseSessionKey : raw.rawId);
}
@@ -178,6 +185,8 @@ export function resolveChannelModelOverride(
parentSessionKey: params.parentSessionKey,
});
if (directMatch) {
// Direct group/session matches win before richer conversation fallback keys,
// preserving the old flat `modelByChannel[channel][groupId]` behavior.
return {
channel: normalizeMessageChannel(channel) ?? normalizeOptionalLowercaseString(channel) ?? "",
model: directMatch.model,
@@ -188,6 +197,8 @@ export function resolveChannelModelOverride(
const { keys, parentKeys } = buildChannelCandidates(params);
if (keys.length === 0 && parentKeys.length === 0) {
// With no concrete conversation identity, only the channel wildcard can
// apply; avoid treating an empty key as a real configured override.
const wildcardModel = normalizeOptionalString(providerEntries["*"]);
if (wildcardModel) {
return {

View File

@@ -9,6 +9,7 @@ export type ResolveNativeCommandSessionTargetsParams = {
lowercaseSessionKey?: boolean;
};
/** Resolve the session key pair used to execute native commands in bound or ad hoc sessions. */
export function resolveNativeCommandSessionTargets(
params: ResolveNativeCommandSessionTargetsParams,
) {

View File

@@ -1,8 +1,10 @@
/** Predicate for channel actions that can be disabled at base or account scope. */
export type ActionGate<T extends Record<string, boolean | undefined>> = (
key: keyof T,
defaultValue?: boolean,
) => boolean;
/** Creates an action gate where account settings override base channel defaults. */
export function createAccountActionGate<T extends Record<string, boolean | undefined>>(params: {
baseActions?: T;
accountActions?: T;
@@ -10,6 +12,7 @@ export function createAccountActionGate<T extends Record<string, boolean | undef
return (key, defaultValue = true) => {
const accountValue = params.accountActions?.[key];
if (accountValue !== undefined) {
// Explicit false is meaningful; only undefined falls through to the broader scope.
return accountValue;
}
const baseValue = params.baseActions?.[key];

View File

@@ -12,6 +12,7 @@ import {
} from "../../routing/session-key.js";
import type { ChannelAccountSnapshot } from "./types.core.js";
/** Creates account id listing/default helpers for one channel config namespace. */
export function createAccountListHelpers(
channelKey: string,
options?: {
@@ -30,6 +31,7 @@ export function createAccountListHelpers(
}
const channel = cfg.channels?.[channelKey] as Record<string, unknown> | undefined;
for (const key of options?.implicitDefaultAccount?.channelKeys ?? []) {
// Root-level credentials imply a default account even when named accounts also exist.
if (hasConfiguredAccountValue(channel?.[key])) {
return true;
}
@@ -93,6 +95,7 @@ export function createAccountListHelpers(
return { listConfiguredAccountIds, listAccountIds, resolveDefaultAccountId };
}
/** Returns whether a config/env value should count as an account being configured. */
export function hasConfiguredAccountValue(value: unknown): boolean {
if (typeof value === "string") {
return value.trim().length > 0;
@@ -100,6 +103,7 @@ export function hasConfiguredAccountValue(value: unknown): boolean {
return value !== undefined && value !== null;
}
/** Combines configured, extra, and implicit account ids into a sorted unique list. */
export function listCombinedAccountIds(params: {
configuredAccountIds: Iterable<string>;
additionalAccountIds?: Iterable<string>;
@@ -128,6 +132,7 @@ export function listCombinedAccountIds(params: {
return [...ids].toSorted((a, b) => a.localeCompare(b));
}
/** Chooses the default account id from listed accounts and optional configured preference. */
export function resolveListedDefaultAccountId(params: {
accountIds: readonly string[];
configuredDefaultAccountId?: string | undefined;
@@ -153,6 +158,7 @@ export function resolveListedDefaultAccountId(params: {
return params.accountIds[0] ?? DEFAULT_ACCOUNT_ID;
}
/** Merges channel-level config with an account override, omitting account container keys. */
export function mergeAccountConfig<TConfig extends Record<string, unknown>>(params: {
channelConfig: TConfig | undefined;
accountConfig: Partial<TConfig> | undefined;
@@ -180,6 +186,7 @@ export function mergeAccountConfig<TConfig extends Record<string, unknown>>(para
accountValue != null &&
!Array.isArray(accountValue)
) {
// Selected nested objects merge shallowly so account overrides can tweak one subkey.
(merged as Record<string, unknown>)[key] = {
...(baseValue as Record<string, unknown>),
...(accountValue as Record<string, unknown>),
@@ -189,6 +196,7 @@ export function mergeAccountConfig<TConfig extends Record<string, unknown>>(para
return merged;
}
/** Resolves an account entry and returns the merged channel/account config. */
export function resolveMergedAccountConfig<TConfig extends Record<string, unknown>>(params: {
channelConfig: TConfig | undefined;
accounts: Record<string, Partial<TConfig>> | undefined;
@@ -214,6 +222,7 @@ type AccountSnapshotInput = {
name?: string | null | undefined;
};
/** Builds a normalized account status snapshot for status/catalog surfaces. */
export function describeAccountSnapshot(params: {
account: AccountSnapshotInput;
configured?: boolean | undefined;
@@ -228,6 +237,7 @@ export function describeAccountSnapshot(params: {
};
}
/** Builds a webhook-mode account snapshot with optional extra status metadata. */
export function describeWebhookAccountSnapshot(params: {
account: AccountSnapshotInput;
configured?: boolean | undefined;

View File

@@ -24,6 +24,7 @@ import type {
import type { ConfiguredBindingConsumer } from "./configured-binding-consumers.js";
import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js";
/** Resolves ACP runtime defaults from the owner agent when it uses the ACP runtime. */
function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgentId: string }): {
acpAgentId?: string;
mode?: string;
@@ -45,6 +46,7 @@ function resolveAgentRuntimeAcpDefaults(params: { cfg: OpenClawConfig; ownerAgen
};
}
/** Resolves cwd for configured ACP bindings from explicit or default agent workspace config. */
function resolveConfiguredBindingWorkspaceCwd(params: {
cfg: OpenClawConfig;
agentId: string;
@@ -64,6 +66,7 @@ function resolveConfiguredBindingWorkspaceCwd(params: {
return undefined;
}
/** Builds the normalized ACP binding spec that backs records and session keys. */
function buildConfiguredAcpSpec(params: {
channel: string;
accountId: string;
@@ -89,6 +92,7 @@ function buildConfiguredAcpSpec(params: {
};
}
/** Builds a target factory for ACP binding config, merging runtime defaults with overrides. */
function buildAcpTargetFactory(params: {
cfg: OpenClawConfig;
binding: ConfiguredBindingRuleConfig;
@@ -144,6 +148,7 @@ function buildAcpTargetFactory(params: {
};
}
/** Configured-binding consumer for ACP targets. */
export const acpConfiguredBindingConsumer: ConfiguredBindingConsumer = {
id: "acp",
supports: (binding) => binding.type === "acp",

View File

@@ -19,6 +19,7 @@ import type {
StatefulBindingTargetSessionResult,
} from "./stateful-target-drivers.js";
/** Converts ACP session metadata or configured binding specs into a stateful target descriptor. */
function toAcpStatefulBindingTargetDescriptor(params: {
cfg: OpenClawConfig;
sessionKey: string;
@@ -67,6 +68,7 @@ function toAcpStatefulBindingTargetDescriptor(params: {
};
}
/** Ensures the ACP configured binding behind a stateful target is ready. */
async function ensureAcpTargetReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
@@ -89,6 +91,7 @@ async function ensureAcpTargetReady(params: {
});
}
/** Ensures the ACP configured binding has a live target session. */
async function ensureAcpTargetSession(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;
@@ -107,6 +110,7 @@ async function ensureAcpTargetSession(params: {
});
}
/** Resets an ACP stateful target through the gateway session authority. */
async function resetAcpTargetInPlace(params: {
cfg: OpenClawConfig;
sessionKey: string;
@@ -128,6 +132,7 @@ async function resetAcpTargetInPlace(params: {
};
}
/** Stateful target driver for configured ACP bindings. */
export const acpStatefulBindingTargetDriver: StatefulBindingTargetDriver = {
id: "acp",
ensureReady: ensureAcpTargetReady,

View File

@@ -4,6 +4,7 @@ type ReactionToolContext = {
currentMessageId?: string | number;
};
/** Resolves the reaction target message id from explicit args or current tool context. */
export function resolveReactionMessageId(params: {
args: Record<string, unknown>;
toolContext?: ReactionToolContext;

View File

@@ -4,12 +4,14 @@ type TokenSourcedAccount = {
tokenSource?: string | null;
};
/** Filters out accounts explicitly configured with tokenSource "none". */
export function listTokenSourcedAccounts<TAccount extends TokenSourcedAccount>(
accounts: readonly TAccount[],
): TAccount[] {
return accounts.filter((account) => account.tokenSource !== "none");
}
/** Creates an action gate that allows an action when any account gate allows it. */
export function createUnionActionGate<TAccount, TKey extends string>(
accounts: readonly TAccount[],
createGate: (account: TAccount) => OptionalDefaultGate<TKey>,

View File

@@ -3,17 +3,22 @@ import type { ChannelApprovalKind } from "../../infra/approval-types.js";
import type { ExecApprovalRequest } from "../../infra/exec-approvals.js";
import type { PluginApprovalRequest } from "../../infra/plugin-approvals.js";
/** Native approval surface where a channel can deliver action controls. */
export type ChannelApprovalNativeSurface = "origin" | "approver-dm";
/** Channel target for a native approval message. */
export type ChannelApprovalNativeTarget = {
to: string;
threadId?: string | number | null;
};
/** Preferred native approval surface when more than one is available. */
export type ChannelApprovalNativeDeliveryPreference = ChannelApprovalNativeSurface | "both";
/** Approval request types that can be rendered natively by a channel. */
export type ChannelApprovalNativeRequest = ExecApprovalRequest | PluginApprovalRequest;
/** Capability summary used before deciding where to render native approval controls. */
export type ChannelApprovalNativeDeliveryCapabilities = {
enabled: boolean;
preferredSurface: ChannelApprovalNativeDeliveryPreference;
@@ -22,6 +27,7 @@ export type ChannelApprovalNativeDeliveryCapabilities = {
notifyOriginWhenDmOnly?: boolean;
};
/** Channel-owned native approval routing and capability adapter. */
export type ChannelApprovalNativeAdapter = {
describeDeliveryCapabilities: (params: {
cfg: OpenClawConfig;

View File

@@ -1,12 +1,17 @@
import type { ChannelApprovalAdapter, ChannelApprovalCapability } from "./types.adapters.js";
import type { ChannelPlugin } from "./types.plugin.js";
/** Returns the raw approval capability advertised by a channel plugin. */
export function resolveChannelApprovalCapability(
plugin?: Pick<ChannelPlugin, "approvalCapability"> | null,
): ChannelApprovalCapability | undefined {
return plugin?.approvalCapability;
}
/**
* Converts a channel approval capability into an adapter only when it exposes at
* least one executable approval surface.
*/
export function resolveChannelApprovalAdapter(
plugin?: Pick<ChannelPlugin, "approvalCapability"> | null,
): ChannelApprovalAdapter | undefined {
@@ -20,6 +25,8 @@ export function resolveChannelApprovalAdapter(
!capability.render &&
!capability.native
) {
// A setup-description-only capability is useful metadata, but it is not an
// adapter the runtime can invoke for approval handling.
return undefined;
}
return {

View File

@@ -1,6 +1,7 @@
import type { ChannelConfiguredBindingProvider } from "./types.adapters.js";
import type { ChannelPlugin } from "./types.plugin.js";
/** Returns a plugin's configured-binding provider surface when present. */
export function resolveChannelConfiguredBindingProvider(
plugin:
| Pick<ChannelPlugin, "bindings">

View File

@@ -15,6 +15,7 @@ import type { ConfiguredBindingResolution } from "./binding-types.js";
const CONFIGURED_BINDING_ROUTE_READY_TIMEOUT_MS = 30_000;
/** Result of resolving a configured binding before a route is finalized. */
export type ConfiguredBindingRouteResult = {
bindingResolution: ConfiguredBindingResolution | null;
route: ResolvedAgentRoute;
@@ -22,6 +23,7 @@ export type ConfiguredBindingRouteResult = {
boundAgentId?: string;
};
/** Result of resolving an existing runtime conversation binding. */
export type RuntimeConversationBindingRouteResult = {
bindingRecord: SessionBindingRecord | null;
route: ResolvedAgentRoute;
@@ -66,6 +68,7 @@ function isPluginOwnedRuntimeBindingRecord(record: SessionBindingRecord | null):
);
}
/** Rewrites a route to a configured stateful binding target when one matches. */
export function resolveConfiguredBindingRoute(
params: {
cfg: OpenClawConfig;
@@ -86,6 +89,7 @@ export function resolveConfiguredBindingRoute(
const boundSessionKey = bindingResolution.statefulTarget.sessionKey.trim();
if (!boundSessionKey) {
// Empty target session keys keep the matched binding for diagnostics but cannot route traffic.
return {
bindingResolution,
route: params.route,
@@ -110,6 +114,7 @@ export function resolveConfiguredBindingRoute(
};
}
/** Rewrites a route to an existing runtime binding when the binding is core-owned. */
export function resolveRuntimeConversationBindingRoute(
params: {
route: ResolvedAgentRoute;
@@ -138,6 +143,7 @@ export function resolveRuntimeConversationBindingRoute(
getSessionBindingService().touch(bindingRecord.bindingId);
if (isPluginOwnedRuntimeBindingRecord(bindingRecord)) {
// Plugin-owned bindings are bookkeeping records; the plugin already owns final delivery.
return {
bindingRecord,
route: params.route,
@@ -162,6 +168,7 @@ export function resolveRuntimeConversationBindingRoute(
};
}
/** Bounds configured binding readiness checks so channel routing cannot hang indefinitely. */
export async function ensureConfiguredBindingRouteReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution | null;

View File

@@ -9,6 +9,7 @@ import {
resolveStatefulBindingTargetBySessionKey,
} from "./stateful-target-drivers.js";
/** Ensures the configured binding target driver is loaded and ready for routing. */
export async function ensureConfiguredBindingTargetReady(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution | null;
@@ -34,6 +35,7 @@ export async function ensureConfiguredBindingTargetReady(params: {
});
}
/** Resets a stateful binding target in place when the owning driver supports it. */
export async function resetConfiguredBindingTargetInPlace(params: {
cfg: OpenClawConfig;
sessionKey: string;
@@ -63,6 +65,7 @@ export async function resetConfiguredBindingTargetInPlace(params: {
});
}
/** Ensures the configured binding target has an active routed session. */
export async function ensureConfiguredBindingTargetSession(params: {
cfg: OpenClawConfig;
bindingResolution: ConfiguredBindingResolution;

View File

@@ -10,10 +10,14 @@ import type {
} from "./types.adapters.js";
import type { ChannelId } from "./types.public.js";
/** Runtime conversation identity used by configured binding lookup. */
export type ConfiguredBindingConversation = ConversationRef;
/** Channel id type used after configured binding channel normalization. */
export type ConfiguredBindingChannel = ChannelId;
/** Raw binding config rule before channel-specific compilation. */
export type ConfiguredBindingRuleConfig = AgentBinding;
/** Stateful target descriptor emitted by a configured binding target factory. */
export type StatefulBindingTargetDescriptor = {
kind: "stateful";
driverId: string;
@@ -22,11 +26,13 @@ export type StatefulBindingTargetDescriptor = {
label?: string;
};
/** Persisted binding record plus the stateful target it materializes. */
export type ConfiguredBindingRecordResolution = {
record: SessionBindingRecord;
statefulTarget: StatefulBindingTargetDescriptor;
};
/** Channel/consumer-owned factory that materializes configured binding targets. */
export type ConfiguredBindingTargetFactory = {
driverId: string;
materialize: (params: {
@@ -35,6 +41,7 @@ export type ConfiguredBindingTargetFactory = {
}) => ConfiguredBindingRecordResolution;
};
/** Channel-compiled binding rule ready for conversation matching. */
export type CompiledConfiguredBinding = {
channel: ConfiguredBindingChannel;
accountPattern?: string;
@@ -46,6 +53,7 @@ export type CompiledConfiguredBinding = {
targetFactory: ConfiguredBindingTargetFactory;
};
/** Full configured binding resolution used by routing and target drivers. */
export type ConfiguredBindingResolution = ConfiguredBindingRecordResolution & {
conversation: ConfiguredBindingConversation;
compiledBinding: CompiledConfiguredBinding;

View File

@@ -2,6 +2,11 @@ import { listChannelCatalogEntries } from "../../plugins/channel-catalog-registr
import type { PluginDiscoveryResult } from "../../plugins/discovery.js";
import { resolveBundledChannelRootScope } from "./bundled-root.js";
/**
* Lists bundled plugin package ids from the catalog for a root-compatible
* caller. The package root argument is retained for older call sites; discovery
* state now owns the actual catalog root.
*/
export function listBundledChannelPluginIdsForRoot(
_packageRoot: string,
env: NodeJS.ProcessEnv = process.env,
@@ -16,6 +21,10 @@ export function listBundledChannelPluginIdsForRoot(
.toSorted((left, right) => left.localeCompare(right));
}
/**
* Lists bundled channel ids from catalog metadata for a root-compatible caller.
* This can differ from plugin ids when one plugin manifest exposes aliases.
*/
export function listBundledChannelIdsForRoot(
_packageRoot: string,
env: NodeJS.ProcessEnv = process.env,
@@ -31,6 +40,7 @@ export function listBundledChannelIdsForRoot(
.toSorted((left, right) => left.localeCompare(right));
}
/** Lists bundled plugin package ids for the active bundled root scope. */
export function listBundledChannelPluginIds(
env: NodeJS.ProcessEnv = process.env,
discovery?: PluginDiscoveryResult,
@@ -42,6 +52,7 @@ export function listBundledChannelPluginIds(
);
}
/** Lists bundled channel ids for the active bundled root scope. */
export function listBundledChannelIds(
env: NodeJS.ProcessEnv = process.env,
discovery?: PluginDiscoveryResult,

View File

@@ -14,8 +14,11 @@ const OPENCLAW_PACKAGE_ROOT =
: process.cwd());
export type BundledChannelRootScope = {
/** Package root used to resolve generated bundled metadata and runtime files. */
packageRoot: string;
/** Stable partition key for bundled module and metadata caches. */
cacheKey: string;
/** Optional override tree that replaces the package's bundled extensions dir. */
pluginsDir?: string;
};
@@ -28,6 +31,10 @@ function derivePackageRootFromExtensionsDir(extensionsDir: string): string {
return parentDir;
}
/**
* Resolves the active bundled channel root. Packaged builds use the OpenClaw
* package root; tests and override flows can point at a replacement plugin tree.
*/
export function resolveBundledChannelRootScope(
env: NodeJS.ProcessEnv = process.env,
): BundledChannelRootScope {
@@ -40,6 +47,9 @@ export function resolveBundledChannelRootScope(
}
const resolvedPluginsDir = path.resolve(bundledPluginsDir);
return {
// Overrides can point either at an `extensions/` tree or directly at a
// generated plugin root; keep the package root aligned with that shape so
// generated metadata and runtime imports share one boundary.
packageRoot:
path.basename(resolvedPluginsDir) === "extensions"
? derivePackageRootFromExtensionsDir(resolvedPluginsDir)

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