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
571 changed files with 6470 additions and 236 deletions

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

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

View File

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

View File

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

View File

@@ -6,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)

View File

@@ -187,6 +187,8 @@ function resolveBundledChannelBoundaryRoot(params: {
].join("\0");
const cached = bundledChannelBoundaryRoots.get(cacheKey);
if (cached) {
// Maintain LRU order because local tests can exercise many synthetic
// bundled roots while the real process normally has one stable root.
bundledChannelBoundaryRoots.delete(cacheKey);
bundledChannelBoundaryRoots.set(cacheKey, cached);
return cached;
@@ -273,6 +275,8 @@ function loadGeneratedBundledChannelModule(params: {
boundaryRootDir: boundaryRoot,
});
} catch (error) {
// Source checkouts and package-local dist builds may import through SDK
// aliases that only the cached loader knows how to rewrite.
const canRetryWithCachedLoader =
isSourceModulePath(modulePath) ||
(isPackageLocalBundledDistModulePath({
@@ -409,6 +413,8 @@ function resolveActiveBundledChannelLoadScope(env: NodeJS.ProcessEnv = process.e
const rootScope = resolveBundledChannelRootScope(env);
const cachedContext = bundledChannelLoadContextsByRoot.get(rootScope.cacheKey);
if (cachedContext) {
// The active bundled root is part of the runtime identity. Partition lazy
// entries by root so tests and overrides cannot reuse another tree's module.
bundledChannelLoadContextsByRoot.delete(rootScope.cacheKey);
bundledChannelLoadContextsByRoot.set(rootScope.cacheKey, cachedContext);
return {
@@ -524,6 +530,10 @@ export function listBundledChannelPluginIds(): readonly ChannelId[] {
return listBundledChannelPluginIdsForRoot(resolveBundledChannelRootScope());
}
/**
* Returns whether bundled package metadata advertises a setup-only capability
* without forcing the setup entrypoint to load.
*/
export function hasBundledChannelPackageSetupFeature(
id: ChannelId,
feature: BundledChannelPackageSetupFeature,
@@ -549,6 +559,8 @@ function resolveBundledChannelMetadata(
return undefined;
}
for (const metadata of listBundledChannelMetadata(rootScope)) {
// Metadata can expose aliases through `manifest.channels`; cache each alias
// to the same package so pre-registry callers resolve channel ids uniformly.
const ids = new Set<ChannelId>([metadata.manifest.id, ...(metadata.manifest.channels ?? [])]);
for (const metadataId of ids) {
loadContext.metadataById.set(metadataId, metadata);
@@ -802,6 +814,10 @@ export function listBundledChannelPlugins(): readonly ChannelPlugin[] {
});
}
/**
* Loads setup entrypoints for every bundled channel that exposes setup-time
* plugin surface such as config promotion, migration, or session repair.
*/
export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] {
const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope();
return listBundledChannelPluginIdsForRoot(rootScope).flatMap((id) => {
@@ -810,6 +826,11 @@ export function listBundledChannelSetupPlugins(): readonly ChannelPlugin[] {
});
}
/**
* Lists bundled setup plugins that explicitly advertise a setup feature.
* Package metadata narrows the scan first; the loaded entrypoint remains the
* final contract check before exposing the plugin.
*/
export function listBundledChannelSetupPluginsByFeature(
feature: keyof NonNullable<BundledChannelSetupEntryRuntimeContract["features"]>,
options: { config?: OpenClawConfig } = {},
@@ -827,6 +848,10 @@ export function listBundledChannelSetupPluginsByFeature(
});
}
/**
* Returns legacy session surfaces from bundled setup entrypoints without
* requiring the normal channel registry to be bootstrapped first.
*/
export function listBundledChannelLegacySessionSurfaces(
options: {
config?: OpenClawConfig;
@@ -849,6 +874,10 @@ export function listBundledChannelLegacySessionSurfaces(
});
}
/**
* Returns setup-time legacy state migration detectors from bundled channels.
* Used by doctor/setup paths before a full channel plugin registry exists.
*/
export function listBundledChannelLegacyStateMigrationDetectors(
options: {
config?: OpenClawConfig;
@@ -873,6 +902,11 @@ export function listBundledChannelLegacyStateMigrationDetectors(
});
}
/**
* Checks optional capabilities from a generated bundled channel entrypoint.
* This is a fast path for callers that need a feature gate without loading the
* full channel plugin implementation.
*/
export function hasBundledChannelEntryFeature(
id: ChannelId,
feature: keyof NonNullable<BundledChannelEntryRuntimeContract["features"]>,
@@ -882,6 +916,10 @@ export function hasBundledChannelEntryFeature(
return hasChannelEntryFeature(entry, feature);
}
/**
* Loads only the bundled account inspector, when the channel exposes the
* lighter entrypoint method.
*/
export function getBundledChannelAccountInspector(
id: ChannelId,
): NonNullable<ChannelPlugin["config"]["inspectAccount"]> | undefined {
@@ -889,16 +927,28 @@ export function getBundledChannelAccountInspector(
return getBundledChannelAccountInspectorForRoot(id, rootScope, loadContext);
}
/**
* Loads a bundled channel plugin from the active bundled root and caches both
* successful and failed lookups for this process.
*/
export function getBundledChannelPlugin(id: ChannelId): ChannelPlugin | undefined {
const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope();
return getBundledChannelPluginForRoot(id, rootScope, loadContext);
}
/**
* Loads only the bundled secrets surface when the generated entrypoint exposes
* it, falling back to the full plugin secrets as needed.
*/
export function getBundledChannelSecrets(id: ChannelId): ChannelPlugin["secrets"] | undefined {
const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope();
return getBundledChannelSecretsForRoot(id, rootScope, loadContext);
}
/**
* Loads the setup-time bundled channel plugin for doctor, setup, and migration
* flows. `env` selects the bundled root override for tests and packaged runs.
*/
export function getBundledChannelSetupPlugin(
id: ChannelId,
env: NodeJS.ProcessEnv = process.env,
@@ -907,6 +957,9 @@ export function getBundledChannelSetupPlugin(
return getBundledChannelSetupPluginForRoot(id, rootScope, loadContext);
}
/**
* Loads setup-time secrets from a bundled setup entrypoint, if available.
*/
export function getBundledChannelSetupSecrets(
id: ChannelId,
env: NodeJS.ProcessEnv = process.env,
@@ -915,6 +968,9 @@ export function getBundledChannelSetupSecrets(
return getBundledChannelSetupSecretsForRoot(id, rootScope, loadContext);
}
/**
* Returns a bundled channel plugin or throws with a stable missing-plugin error.
*/
export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin {
const plugin = getBundledChannelPlugin(id);
if (!plugin) {
@@ -923,6 +979,11 @@ export function requireBundledChannelPlugin(id: ChannelId): ChannelPlugin {
return plugin;
}
/**
* Passes process runtime helpers into a generated bundled channel entrypoint.
* This is intentionally explicit so bundled modules do not import core runtime
* state directly.
*/
export function setBundledChannelRuntime(id: ChannelId, runtime: PluginRuntime): void {
const { rootScope, loadContext } = resolveActiveBundledChannelLoadScope();
const setter = getLazyGeneratedBundledChannelEntryForRoot(id, rootScope, loadContext)?.entry

View File

@@ -24,18 +24,28 @@ import { buildManifestChannelMeta } from "./channel-meta.js";
import type { ChannelMeta } from "./types.public.js";
export type ChannelUiMetaEntry = {
/** Channel id used by config, setup, and runtime selection. */
id: string;
/** Short label for compact channel pickers. */
label: string;
/** Longer label for account/setup screens that need disambiguation. */
detailLabel: string;
/** Optional platform icon name for native UI surfaces. */
systemImage?: string;
};
export type ChannelUiCatalog = {
/** Ordered channel entries for modern callers. */
entries: ChannelUiMetaEntry[];
/** Legacy ordered id list kept in sync with `entries`. */
order: string[];
/** Legacy lookup map from channel id to compact label. */
labels: Record<string, string>;
/** Legacy lookup map from channel id to detail label. */
detailLabels: Record<string, string>;
/** Legacy lookup map from channel id to native icon name. */
systemImages: Record<string, string>;
/** Lookup map for callers that need the full entry by id. */
byId: Record<string, ChannelUiMetaEntry>;
};
@@ -43,12 +53,19 @@ export type ChannelPluginCatalogInstall = PluginPackageInstall &
({ clawhubSpec: string } | { npmSpec: string });
export type ChannelPluginCatalogEntry = {
/** Channel id surfaced to setup, docs, and config. */
id: string;
/** Owning plugin id when the channel metadata comes from a plugin manifest. */
pluginId?: string;
/** Discovery origin used to resolve catalog shadowing and trust. */
origin?: PluginOrigin;
/** True when a fallback entry is linked to the shipped official catalog. */
trustedSourceLinkedOfficialInstall?: boolean;
/** Normalized presentation metadata for setup and docs. */
meta: ChannelMeta;
/** Install choices exposed to setup/doctor flows. */
install: ChannelPluginCatalogInstall;
/** Human-readable install source details derived from `install`. */
installSource?: PluginInstallSourceInfo;
};
@@ -276,6 +293,8 @@ function resolveInstallInfo(params: {
parsedNpmSpec?.selectorKind === "none" &&
(!parsedPackageName || parsedNpmSpec.name === parsedPackageName.name)
) {
// Prerelease channel plugins should install the exact advertised package
// version unless the catalog already selected a range, tag, or specifier.
npmSpec = `${parsedNpmSpec.name}@${packageVersion}`;
}
if (!clawhubSpec && !npmSpec) {
@@ -390,6 +409,10 @@ function buildExternalCatalogEntry(
});
}
/**
* Builds the channel catalog shape consumed by UI code, including the legacy
* lookup maps that older setup surfaces still read.
*/
export function buildChannelUiCatalog(
plugins: Array<{ id: string; meta: ChannelMeta }>,
): ChannelUiCatalog {
@@ -462,6 +485,8 @@ export function listRawChannelPluginCatalogEntries(
}
for (const entry of loadOfficialCatalogEntries(options)) {
// Official fallback entries fill gaps for shipped channels, but any
// discovered install source must win because it reflects local reality.
const priority = FALLBACK_CATALOG_PRIORITY;
const existing = resolved.get(entry.id);
if (!existing || priority < existing.priority) {
@@ -505,6 +530,10 @@ export function listChannelPluginCatalogEntries(
return listRawChannelPluginCatalogEntries(options);
}
/**
* Finds a catalog entry by normalized channel id. Returns raw catalog data, so
* execution-facing callers should still apply trust filtering before loading.
*/
export function getChannelPluginCatalogEntry(
id: string,
options: CatalogOptions = {},

View File

@@ -5,6 +5,11 @@ import type { ChannelMeta } from "./types.core.js";
type ArrayFieldMode = "defined" | "non-empty";
type OptionalStringMode = "defined" | "truthy";
/**
* Normalizes manifest channel metadata into the runtime/UI channel meta shape.
* Callers choose whether empty array/string fields are preserved or omitted so
* catalog and plugin-runtime surfaces can keep their historical semantics.
*/
export function buildManifestChannelMeta(params: {
id: string;
channel: PluginPackageChannel;

View File

@@ -1,9 +1,13 @@
export type ChannelRuntimeContextKey = {
/** Channel/plugin id that owns the runtime context. */
channelId: string;
/** Optional configured account id; omitted means channel-wide runtime state. */
accountId?: string | null;
/** Capability namespace for the context, such as `approval.native`. */
capability: string;
};
/** Runtime context lifecycle event delivered to matching watchers. */
export type ChannelRuntimeContextEvent = {
type: "registered" | "unregistered";
key: {
@@ -15,6 +19,7 @@ export type ChannelRuntimeContextEvent = {
};
export type ChannelRuntimeContextRegistry = {
/** Register one context lease; disposing the lease unregisters only that exact registration. */
register: (
params: ChannelRuntimeContextKey & {
context: unknown;
@@ -22,7 +27,9 @@ export type ChannelRuntimeContextRegistry = {
},
) => { dispose: () => void };
// oxlint-disable-next-line typescript/no-unnecessary-type-parameters -- Runtime context values are caller-typed by key.
/** Read the current context for an exact key, typed by caller-owned capability convention. */
get: <T = unknown>(params: ChannelRuntimeContextKey) => T | undefined;
/** Watch registration changes matching the optional key filters. */
watch: (params: {
channelId?: string;
accountId?: string | null;

View File

@@ -5,8 +5,10 @@ import {
import { normalizeStringEntries } from "@openclaw/normalization-core/string-normalization";
import { parseStrictInteger } from "../../infra/parse-finite-number.js";
/** Service prefix that maps a user-facing target prefix to a channel service id. */
export type ServicePrefix<TService extends string> = { prefix: string; service: TService };
/** Prefix groups used to parse chat id, GUID, and human-readable chat identifiers. */
export type ChatTargetPrefixesParams = {
trimmed: string;
lower: string;
@@ -15,13 +17,16 @@ export type ChatTargetPrefixesParams = {
chatIdentifierPrefixes: string[];
};
/** Parsed conversation target from strict chat id/GUID/identifier prefixes. */
export type ParsedChatTarget =
| { kind: "chat_id"; chatId: number }
| { kind: "chat_guid"; chatGuid: string }
| { kind: "chat_identifier"; chatIdentifier: string };
/** Parsed allowlist entry that may authorize either a conversation target or sender handle. */
export type ParsedChatAllowTarget = ParsedChatTarget | { kind: "handle"; handle: string };
/** Inputs for checking a parsed allowlist against a sender and optional conversation target. */
export type ChatSenderAllowParams = {
allowFrom: Array<string | number>;
sender: string;
@@ -31,6 +36,7 @@ export type ChatSenderAllowParams = {
allowConversationTargets?: boolean | null;
};
/** Matches allowlist entries against sender handles and opt-in conversation targets. */
export function isAllowedParsedChatSender(params: {
allowFrom: Array<string | number>;
sender: string;
@@ -51,6 +57,7 @@ export function isAllowedParsedChatSender(params: {
const senderNormalized = params.normalizeSender(params.sender);
const allowConversationTargets = params.allowConversationTargets === true;
// Conversation targets authorize whole chats, so callers must opt in per channel surface.
const chatId = allowConversationTargets ? (params.chatId ?? undefined) : undefined;
const chatGuid = allowConversationTargets ? normalizeOptionalString(params.chatGuid) : undefined;
const chatIdentifier = allowConversationTargets
@@ -91,6 +98,7 @@ function startsWithAnyPrefix(value: string, prefixes: readonly string[]): boolea
return prefixes.some((prefix) => value.startsWith(prefix));
}
/** Resolves service-prefixed handles, delegating chat-looking remainders to a parser. */
export function resolveServicePrefixedTarget<TService extends string, TTarget>(params: {
trimmed: string;
lower: string;
@@ -115,6 +123,7 @@ export function resolveServicePrefixedTarget<TService extends string, TTarget>(p
return null;
}
/** Resolves service-prefixed targets while preserving nested chat target grammar. */
export function resolveServicePrefixedChatTarget<TService extends string, TTarget>(params: {
trimmed: string;
lower: string;
@@ -140,6 +149,7 @@ export function resolveServicePrefixedChatTarget<TService extends string, TTarge
});
}
/** Parses strict chat target prefixes and throws when a matching prefix has invalid payload. */
export function parseChatTargetPrefixesOrThrow(
params: ChatTargetPrefixesParams,
): ParsedChatTarget | null {
@@ -177,6 +187,7 @@ export function parseChatTargetPrefixesOrThrow(
return null;
}
/** Parses service-prefixed allowlist entries using the channel-owned allow target parser. */
export function resolveServicePrefixedAllowTarget<TAllowTarget>(params: {
trimmed: string;
lower: string;
@@ -196,6 +207,7 @@ export function resolveServicePrefixedAllowTarget<TAllowTarget>(params: {
return null;
}
/** Parses allowlist entries that may be service-prefixed handles or native chat targets. */
export function resolveServicePrefixedOrChatAllowTarget<
TAllowTarget extends ParsedChatAllowTarget,
>(params: {
@@ -230,6 +242,7 @@ export function resolveServicePrefixedOrChatAllowTarget<
return null;
}
/** Creates a reusable sender matcher with channel-specific parsing and normalization. */
export function createAllowedChatSenderMatcher(params: {
normalizeSender: (sender: string) => string;
parseAllowTarget: (entry: string) => ParsedChatAllowTarget;
@@ -249,6 +262,7 @@ export function createAllowedChatSenderMatcher(params: {
});
}
/** Parses allowlist chat targets leniently, returning null for invalid prefix payloads. */
export function parseChatAllowTargetPrefixes(
params: ChatTargetPrefixesParams,
): ParsedChatTarget | null {

View File

@@ -13,6 +13,7 @@ function isConfiguredSecretValue(value: unknown): boolean {
return Boolean(value);
}
/** Writes an account enabled flag into a channel config section. */
export function setAccountEnabledInConfigSection(params: {
cfg: OpenClawConfig;
sectionKey: string;
@@ -25,6 +26,7 @@ export function setAccountEnabledInConfigSection(params: {
const base = channels?.[params.sectionKey] as ChannelSection | undefined;
const hasAccounts = Boolean(base?.accounts);
if (params.allowTopLevel && accountKey === DEFAULT_ACCOUNT_ID && !hasAccounts) {
// Single-account legacy sections store default enabled state at the channel root.
return {
...params.cfg,
channels: {
@@ -57,6 +59,7 @@ export function setAccountEnabledInConfigSection(params: {
} as OpenClawConfig;
}
/** Removes an account config entry or the whole single-account section when empty. */
export function deleteAccountFromConfigSection(params: {
cfg: OpenClawConfig;
sectionKey: string;
@@ -119,6 +122,7 @@ export function deleteAccountFromConfigSection(params: {
return nextCfg;
}
/** Deletes selected fields from one account entry and reports whether configured values existed. */
export function clearAccountEntryFields<TAccountEntry extends object>(params: {
accounts?: Record<string, TAccountEntry>;
accountId: string;

View File

@@ -18,9 +18,12 @@ type ExtendableZodObject = ZodTypeAny & {
extend: (shape: Record<string, ZodTypeAny>) => ZodTypeAny;
};
/** Schema for one channel allowlist entry. */
export const AllowFromEntrySchema = z.union([z.string(), z.number()]);
/** Optional allowlist schema shared by channel config builders. */
export const AllowFromListSchema = z.array(AllowFromEntrySchema).optional();
/** Builds an optional nested `dm` config schema with standard DM fields. */
export function buildNestedDmConfigSchema(extraShape?: ZodRawShape) {
const baseShape = {
enabled: z.boolean().optional(),
@@ -30,6 +33,10 @@ export function buildNestedDmConfigSchema(extraShape?: ZodRawShape) {
return z.object(extraShape ? { ...baseShape, ...extraShape } : baseShape).optional();
}
/**
* Extends an account schema with `accounts` and `defaultAccount` for channels
* that support arbitrary named account config blocks.
*/
export function buildCatchallMultiAccountChannelSchema<T extends ExtendableZodObject>(
accountSchema: T,
): T {
@@ -112,6 +119,7 @@ function safeParseJsonSchema(
};
}
/** Builds a channel config schema directly from JSON Schema plus runtime parser metadata. */
export function buildJsonChannelConfigSchema(
schema: JsonSchemaObject,
options?: BuildJsonChannelConfigSchemaOptions,
@@ -126,6 +134,10 @@ export function buildJsonChannelConfigSchema(
};
}
/**
* Builds a channel config schema from Zod, using Zod's JSON Schema export when
* available while keeping the original Zod runtime parser.
*/
export function buildChannelConfigSchema(
schema: ZodTypeAny,
options?: BuildChannelConfigSchemaOptions,
@@ -158,6 +170,7 @@ export function buildChannelConfigSchema(
};
}
/** Returns a schema for channels that intentionally accept no config keys. */
export function emptyChannelConfigSchema(): ChannelConfigSchema {
return {
schema: {

View File

@@ -14,17 +14,20 @@ type ConfigWritePolicyConfig = {
channels?: Record<string, unknown>;
};
/** Channel/account scope used by config-write authorization checks. */
export type ConfigWriteScopeLike<TChannelId extends string = string> = {
channelId?: TChannelId | null;
accountId?: string | null;
};
/** Normalized config-write target derived from explicit scope or config path. */
export type ConfigWriteTargetLike<TChannelId extends string = string> =
| { kind: "global" }
| { kind: "channel"; scope: { channelId: TChannelId } }
| { kind: "account"; scope: { channelId: TChannelId; accountId: string } }
| { kind: "ambiguous"; scopes: ConfigWriteScopeLike<TChannelId>[] };
/** Config-write authorization result shared by core and SDK adapters. */
export type ConfigWriteAuthorizationResultLike<TChannelId extends string = string> =
| { allowed: true }
| {
@@ -68,6 +71,7 @@ function resolveChannelAccountConfig(
return resolveAccountEntry(channelConfig.accounts, normalizeAccountId(accountId));
}
/** Resolves whether config writes are enabled for one channel/account scope. */
export function resolveChannelConfigWritesShared(params: {
cfg: ConfigWritePolicyConfig;
channelId?: string | null;
@@ -82,6 +86,7 @@ export function resolveChannelConfigWritesShared(params: {
return value !== false;
}
/** Authorizes a channel-initiated config write against origin and target scopes. */
export function authorizeConfigWriteShared<TChannelId extends string>(params: {
cfg: ConfigWritePolicyConfig;
origin?: ConfigWriteScopeLike<TChannelId>;
@@ -118,6 +123,7 @@ export function authorizeConfigWriteShared<TChannelId extends string>(params: {
continue;
}
seen.add(key);
// Ambiguous path targets can reference the same scope more than once; check each once.
if (
!resolveChannelConfigWritesShared({
cfg: params.cfg,
@@ -135,6 +141,7 @@ export function authorizeConfigWriteShared<TChannelId extends string>(params: {
return { allowed: true };
}
/** Converts an explicit scope into the closest concrete config-write target. */
export function resolveExplicitConfigWriteTargetShared<TChannelId extends string>(
scope: ConfigWriteScopeLike<TChannelId>,
): ConfigWriteTargetLike<TChannelId> {
@@ -148,6 +155,7 @@ export function resolveExplicitConfigWriteTargetShared<TChannelId extends string
return { kind: "account", scope: { channelId: scope.channelId, accountId } };
}
/** Infers the config-write target affected by a config path. */
export function resolveConfigWriteTargetFromPathShared<TChannelId extends string>(params: {
path: string[];
normalizeChannelId: (raw: string) => TChannelId | null | undefined;
@@ -156,6 +164,8 @@ export function resolveConfigWriteTargetFromPathShared<TChannelId extends string
return { kind: "global" };
}
if (params.path.length < 2) {
// Replacing the whole channels map can affect multiple channel policies,
// so channel-originated writes need an explicit operator-admin bypass.
return { kind: "ambiguous", scopes: [] };
}
const channelId = params.normalizeChannelId(params.path[1] ?? "");
@@ -163,12 +173,16 @@ export function resolveConfigWriteTargetFromPathShared<TChannelId extends string
return { kind: "ambiguous", scopes: [] };
}
if (params.path.length === 2) {
// Replacing one channel root can drop nested account configWrites flags.
// Treat it as ambiguous instead of silently checking only the channel flag.
return { kind: "ambiguous", scopes: [{ channelId }] };
}
if (params.path[2] !== "accounts") {
return { kind: "channel", scope: { channelId } };
}
if (params.path.length < 4) {
// Replacing the accounts collection can affect accounts with different
// configWrites settings, so require a concrete account path.
return { kind: "ambiguous", scopes: [{ channelId }] };
}
return resolveExplicitConfigWriteTargetShared({
@@ -177,6 +191,7 @@ export function resolveConfigWriteTargetFromPathShared<TChannelId extends string
});
}
/** Returns whether an internal admin-scoped message can bypass config-write policy. */
export function canBypassConfigWritePolicyShared(params: {
channel?: string | null;
gatewayClientScopes?: string[] | null;
@@ -188,6 +203,7 @@ export function canBypassConfigWritePolicyShared(params: {
);
}
/** Formats a user-facing denial reason for blocked config writes. */
export function formatConfigWriteDeniedMessageShared<TChannelId extends string>(params: {
result: Exclude<ConfigWriteAuthorizationResultLike<TChannelId>, { allowed: true }>;
fallbackChannelId?: TChannelId | null;

View File

@@ -12,14 +12,19 @@ import {
type ConfigWriteTargetLike,
} from "./config-write-policy-shared.js";
import type { ChannelId } from "./types.core.js";
/** Channel/account scope used by channel plugin config-write checks. */
export type ConfigWriteScope = ConfigWriteScopeLike;
/** Normalized config-write target used by channel plugin callers. */
export type ConfigWriteTarget = ConfigWriteTargetLike;
/** Authorization result for channel-initiated config writes. */
export type ConfigWriteAuthorizationResult = ConfigWriteAuthorizationResultLike;
function isInternalConfigWriteMessageChannel(channel?: string | null): boolean {
return normalizeLowercaseStringOrEmpty(channel) === "webchat";
}
/** Resolves whether config writes are enabled for a channel/account. */
export function resolveChannelConfigWrites(params: {
cfg: OpenClawConfig;
channelId?: ChannelId | null;
@@ -28,6 +33,7 @@ export function resolveChannelConfigWrites(params: {
return resolveChannelConfigWritesShared(params);
}
/** Authorizes a config write against origin and resolved target scopes. */
export function authorizeConfigWrite(params: {
cfg: OpenClawConfig;
origin?: ConfigWriteScope;
@@ -37,10 +43,12 @@ export function authorizeConfigWrite(params: {
return authorizeConfigWriteShared(params);
}
/** Converts an explicit channel/account scope into a config-write target. */
export function resolveExplicitConfigWriteTarget(scope: ConfigWriteScope): ConfigWriteTarget {
return resolveExplicitConfigWriteTargetShared(scope);
}
/** Infers the config-write target touched by a config path. */
export function resolveConfigWriteTargetFromPath(path: string[]): ConfigWriteTarget {
return resolveConfigWriteTargetFromPathShared({
path,
@@ -48,6 +56,7 @@ export function resolveConfigWriteTargetFromPath(path: string[]): ConfigWriteTar
});
}
/** Allows internal webchat operator-admin messages to bypass channel config-write policy. */
export function canBypassConfigWritePolicy(params: {
channel?: string | null;
gatewayClientScopes?: string[] | null;
@@ -58,6 +67,7 @@ export function canBypassConfigWritePolicy(params: {
});
}
/** Formats a user-facing denial message for config-write policy failures. */
export function formatConfigWriteDeniedMessage(params: {
result: Exclude<ConfigWriteAuthorizationResult, { allowed: true }>;
fallbackChannelId?: ChannelId | null;

View File

@@ -1,6 +1,7 @@
import { acpConfiguredBindingConsumer } from "./acp-configured-binding-consumer.js";
import { registerConfiguredBindingConsumer } from "./configured-binding-consumers.js";
/** Registers built-in configured-binding consumers such as ACP. */
export function ensureConfiguredBindingBuiltinsRegistered(): void {
registerConfiguredBindingConsumer(acpConfiguredBindingConsumer);
}

View File

@@ -17,6 +17,7 @@ import type {
// Configured bindings are channel-owned rules compiled from config, separate
// from runtime plugin-owned conversation bindings.
/** Compiled configured binding rules grouped by channel for route-time lookup. */
export type CompiledConfiguredBindingRegistry = {
rulesByChannel: Map<ConfiguredBindingChannel, CompiledConfiguredBinding[]>;
};
@@ -126,11 +127,15 @@ function compileConfiguredBindingRegistry(params: {
for (const binding of listConfiguredBindings(params.cfg)) {
const bindingConversationId = resolveBindingConversationId(binding);
if (!bindingConversationId) {
// A configured binding without a peer id cannot be matched to inbound
// conversations, so keep it out of the route-time registry.
continue;
}
const resolvedChannel = resolveConfiguredBindingAdapter(binding.match.channel);
if (!resolvedChannel) {
// Unknown channels or channels without binding adapters are ignored here;
// doctor/config validation owns user-facing warnings for bad config.
continue;
}
@@ -140,6 +145,8 @@ function compileConfiguredBindingRegistry(params: {
conversationId: bindingConversationId,
});
if (!target) {
// Providers may reject conversation ids that are valid config strings but
// not valid native conversation targets for that channel.
continue;
}
@@ -152,6 +159,8 @@ function compileConfiguredBindingRegistry(params: {
provider: resolvedChannel.provider,
});
if (!rule) {
// Consumers own binding-type support. Unsupported types should not create
// partial registry entries that could win matching later.
continue;
}
pushCompiledRule(rulesByChannel, rule);
@@ -162,18 +171,21 @@ function compileConfiguredBindingRegistry(params: {
};
}
/** Compiles configured binding rules from current config and loaded channel plugins. */
export function resolveCompiledBindingRegistry(
cfg: OpenClawConfig,
): CompiledConfiguredBindingRegistry {
return compileConfiguredBindingRegistry({ cfg });
}
/** Compiles configured bindings for startup diagnostics without caching the result. */
export function primeCompiledBindingRegistry(
cfg: OpenClawConfig,
): CompiledConfiguredBindingRegistry {
return compileConfiguredBindingRegistry({ cfg });
}
/** Counts compiled binding rules and channels for diagnostics. */
export function countCompiledBindingRegistry(registry: CompiledConfiguredBindingRegistry): {
bindingCount: number;
channelCount: number;

View File

@@ -7,14 +7,18 @@ import type {
} from "./binding-types.js";
import type { ChannelConfiguredBindingConversationRef } from "./types.adapters.js";
/** Parsed routing fields extracted from a configured binding session key. */
export type ParsedConfiguredBindingSessionKey = {
channel: string;
accountId: string;
};
/** Consumer contract for binding config types that materialize stateful targets. */
export type ConfiguredBindingConsumer = {
id: string;
/** Returns true when this consumer owns the raw binding type. */
supports: (binding: ConfiguredBindingRuleConfig) => boolean;
/** Builds the stateful target factory after the channel has compiled the conversation target. */
buildTargetFactory: (params: {
cfg: OpenClawConfig;
binding: ConfiguredBindingRuleConfig;
@@ -23,7 +27,9 @@ export type ConfiguredBindingConsumer = {
target: ChannelConfiguredBindingConversationRef;
bindingConversationId: string;
}) => ConfiguredBindingTargetFactory | null;
/** Parses a target session key back into channel/account scope for reverse lookup. */
parseSessionKey?: (params: { sessionKey: string }) => ParsedConfiguredBindingSessionKey | null;
/** Optional exact-match hook when the materialized record key is not enough. */
matchesSessionKey?: (params: {
sessionKey: string;
compiledBinding: CompiledConfiguredBinding;
@@ -34,10 +40,12 @@ export type ConfiguredBindingConsumer = {
const registeredConfiguredBindingConsumers = new Map<string, ConfiguredBindingConsumer>();
/** Lists registered configured-binding consumers in registration order. */
export function listConfiguredBindingConsumers(): ConfiguredBindingConsumer[] {
return [...registeredConfiguredBindingConsumers.values()];
}
/** Resolves the first registered consumer that supports a raw binding config. */
export function resolveConfiguredBindingConsumer(
binding: ConfiguredBindingRuleConfig,
): ConfiguredBindingConsumer | null {
@@ -49,6 +57,7 @@ export function resolveConfiguredBindingConsumer(
return null;
}
/** Registers a configured-binding consumer once by trimmed id. */
export function registerConfiguredBindingConsumer(consumer: ConfiguredBindingConsumer): void {
const id = consumer.id.trim();
if (!id) {

View File

@@ -14,6 +14,7 @@ import type {
ChannelConfiguredBindingMatch,
} from "./types.adapters.js";
/** Returns account match strength: 0 no match, 1 wildcard, 2 exact/default. */
export function resolveAccountMatchPriority(match: string | undefined, actual: string): 0 | 1 | 2 {
const trimmed = (match ?? "").trim();
if (!trimmed) {
@@ -38,11 +39,13 @@ function matchCompiledBindingConversation(params: {
});
}
/** Normalizes a configured binding channel id into the compiled-binding channel type. */
export function resolveCompiledBindingChannel(raw: string): ConfiguredBindingChannel | null {
const normalized = normalizeOptionalLowercaseString(raw);
return normalized ? (normalized as ConfiguredBindingChannel) : null;
}
/** Converts a runtime conversation ref into normalized channel/account/conversation fields. */
export function toConfiguredBindingConversationRef(conversation: ConversationRef): {
channel: ConfiguredBindingChannel;
accountId: string;
@@ -62,6 +65,7 @@ export function toConfiguredBindingConversationRef(conversation: ConversationRef
};
}
/** Materializes a matched compiled binding into its persisted binding record and target. */
export function materializeConfiguredBindingRecord(params: {
rule: CompiledConfiguredBinding;
accountId: string;
@@ -73,6 +77,7 @@ export function materializeConfiguredBindingRecord(params: {
});
}
/** Selects the highest-priority compiled binding for a canonical conversation ref. */
export function resolveMatchingConfiguredBinding(params: {
rules: CompiledConfiguredBinding[];
conversation: ReturnType<typeof toConfiguredBindingConversationRef>;
@@ -106,6 +111,7 @@ export function resolveMatchingConfiguredBinding(params: {
}
const matchPriority = match.matchPriority ?? 0;
if (accountMatchPriority === 2) {
// Exact/default account matches outrank wildcard account rules even at equal match priority.
if (!exactMatch || matchPriority > (exactMatch.match.matchPriority ?? 0)) {
exactMatch = { rule, match };
}

View File

@@ -46,6 +46,7 @@ function resolveMaterializedConfiguredBinding(params: {
};
}
/** Compiles configured bindings once and reports their diagnostic counts. */
export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }): {
bindingCount: number;
channelCount: number;
@@ -53,6 +54,7 @@ export function primeConfiguredBindingRegistry(params: { cfg: OpenClawConfig }):
return countCompiledBindingRegistry(primeCompiledBindingRegistry(params.cfg));
}
/** Resolves a configured binding record from raw conversation coordinates. */
export function resolveConfiguredBindingRecord(params: {
cfg: OpenClawConfig;
channel: string;
@@ -75,6 +77,7 @@ export function resolveConfiguredBindingRecord(params: {
});
}
/** Resolves a configured binding record from a canonical conversation ref. */
export function resolveConfiguredBindingRecordForConversation(params: {
cfg: OpenClawConfig;
conversation: ConversationRef;
@@ -86,6 +89,7 @@ export function resolveConfiguredBindingRecordForConversation(params: {
return resolved.materializedTarget;
}
/** Resolves the full configured binding match, compiled rule, record, and stateful target. */
export function resolveConfiguredBinding(params: {
cfg: OpenClawConfig;
conversation: ConversationRef;
@@ -102,6 +106,7 @@ export function resolveConfiguredBinding(params: {
};
}
/** Resolves a configured binding record by target session key from the compiled registry. */
export function resolveConfiguredBindingRecordBySessionKey(params: {
cfg: OpenClawConfig;
sessionKey: string;

View File

@@ -7,6 +7,7 @@ import {
resolveCompiledBindingChannel,
} from "./configured-binding-match.js";
/** Resolves a configured binding record from an already compiled registry by target session key. */
export function resolveConfiguredBindingRecordBySessionKeyFromRegistry(params: {
registry: CompiledConfiguredBindingRegistry;
sessionKey: string;
@@ -56,9 +57,12 @@ export function resolveConfiguredBindingRecordBySessionKeyFromRegistry(params: {
}) ?? materializedTarget.record.targetSessionKey === sessionKey;
if (matchesSessionKey) {
if (accountMatchPriority === 2) {
// Exact/default account matches win over wildcard rules for the same session key.
exactMatch = materializedTarget;
break;
}
// Keep scanning for an exact/default account rule before accepting a
// wildcard account match for the same parsed session key.
wildcardMatch = materializedTarget;
}
}

View File

@@ -5,12 +5,14 @@ import {
listBundledChannelIdsForPackageState,
} from "./package-state-probes.js";
/** Lists bundled channels with declarative configured-state metadata. */
export function listBundledChannelIdsWithConfiguredState(
discovery?: PluginDiscoveryResult,
): string[] {
return listBundledChannelIdsForPackageState("configuredState", discovery);
}
/** Checks whether a bundled channel appears configured without loading full plugin source. */
export function hasBundledChannelConfiguredState(params: {
channelId: string;
cfg: OpenClawConfig;

View File

@@ -2,6 +2,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { getChannelPlugin } from "./registry.js";
import type { ChannelId } from "./types.public.js";
/** Creates a channel-owned conversation binding manager when the plugin exposes one. */
export async function createChannelConversationBindingManager(params: {
channelId: ChannelId;
cfg: OpenClawConfig;
@@ -17,6 +18,7 @@ export async function createChannelConversationBindingManager(params: {
});
}
/** Updates idle timeout metadata for runtime conversation bindings targeting a session key. */
export function setChannelConversationBindingIdleTimeoutBySessionKey(params: {
channelId: ChannelId;
targetSessionKey: string;
@@ -40,6 +42,7 @@ export function setChannelConversationBindingIdleTimeoutBySessionKey(params: {
});
}
/** Updates max-age metadata for runtime conversation bindings targeting a session key. */
export function setChannelConversationBindingMaxAgeBySessionKey(params: {
channelId: ChannelId;
targetSessionKey: string;

View File

@@ -1,8 +1,10 @@
import type { ChannelDirectoryAdapter } from "./types.adapters.js";
/** Shared self resolver for directory adapters that cannot identify the current account. */
export const nullChannelDirectorySelf: NonNullable<ChannelDirectoryAdapter["self"]> = async () =>
null;
/** Shared list resolver for directory adapters with no peer or group directory entries. */
export const emptyChannelDirectoryList: NonNullable<
ChannelDirectoryAdapter["listPeers"]
> = async () => [];

View File

@@ -15,6 +15,7 @@ function resolveDirectoryLimit(limit?: number | null): number | undefined {
return typeof limit === "number" && limit > 0 ? limit : undefined;
}
/** Applies a case-insensitive substring query and positive limit to directory ids. */
export function applyDirectoryQueryAndLimit(
ids: string[],
params: { query?: string | null; limit?: number | null },
@@ -34,6 +35,7 @@ export function applyDirectoryQueryAndLimit(
return filtered;
}
/** Wraps normalized ids as directory entries of one peer kind. */
export function toDirectoryEntries(kind: "user" | "group", ids: string[]): ChannelDirectoryEntry[] {
const entries: ChannelDirectoryEntry[] = [];
for (const id of ids) {
@@ -64,6 +66,7 @@ function collectDirectoryIds(
for (const value of values) {
const entry = normalizeOptionalString(String(value)) ?? "";
if (!entry || entry === "*") {
// Directory listings should not expose wildcard allowlist entries as concrete peers.
continue;
}
const normalized = normalizeId ? normalizeId(entry) : entry;
@@ -79,6 +82,7 @@ function dedupeDirectoryIds(ids: string[]): string[] {
return uniqueStrings(ids);
}
/** Collects normalized directory ids across sources while skipping blank and wildcard values. */
export function collectNormalizedDirectoryIds(params: {
sources: Iterable<unknown>[];
normalizeId: (entry: string) => string | null | undefined;
@@ -100,6 +104,7 @@ export function collectNormalizedDirectoryIds(params: {
return Array.from(ids);
}
/** Lists directory entries from raw source iterables with normalization, query, and limit. */
export function listDirectoryEntriesFromSources(params: {
kind: "user" | "group";
sources: Iterable<unknown>[];
@@ -114,6 +119,7 @@ export function listDirectoryEntriesFromSources(params: {
return toDirectoryEntries(params.kind, applyDirectoryQueryAndLimit(ids, params));
}
/** Lists entries from an optional inspected account, returning empty when absent. */
export function listInspectedDirectoryEntriesFromSources<InspectedAccount>(
params: DirectoryConfigParams & {
kind: "user" | "group";
@@ -138,6 +144,7 @@ export function listInspectedDirectoryEntriesFromSources<InspectedAccount>(
});
}
/** Creates an async lister around an optional inspected-account directory source. */
export function createInspectedDirectoryEntriesLister<InspectedAccount>(params: {
kind: "user" | "group";
inspectAccount: (
@@ -154,6 +161,7 @@ export function createInspectedDirectoryEntriesLister<InspectedAccount>(params:
});
}
/** Lists entries from a required resolved account directory source. */
export function listResolvedDirectoryEntriesFromSources<ResolvedAccount>(
params: DirectoryConfigParams & {
kind: "user" | "group";
@@ -172,6 +180,7 @@ export function listResolvedDirectoryEntriesFromSources<ResolvedAccount>(
});
}
/** Creates an async lister around a required resolved-account directory source. */
export function createResolvedDirectoryEntriesLister<ResolvedAccount>(params: {
kind: "user" | "group";
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;
@@ -185,6 +194,7 @@ export function createResolvedDirectoryEntriesLister<ResolvedAccount>(params: {
});
}
/** Lists user directory entries from an allowFrom-style array. */
export function listDirectoryUserEntriesFromAllowFrom(params: {
allowFrom?: readonly unknown[];
query?: string | null;
@@ -200,6 +210,7 @@ export function listDirectoryUserEntriesFromAllowFrom(params: {
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
/** Lists user entries from allowFrom values plus ids stored as map keys. */
export function listDirectoryUserEntriesFromAllowFromAndMapKeys(params: {
allowFrom?: readonly unknown[];
map?: Record<string, unknown>;
@@ -221,6 +232,7 @@ export function listDirectoryUserEntriesFromAllowFromAndMapKeys(params: {
return toDirectoryEntries("user", applyDirectoryQueryAndLimit(ids, params));
}
/** Lists group directory entries from object map keys. */
export function listDirectoryGroupEntriesFromMapKeys(params: {
groups?: Record<string, unknown>;
query?: string | null;
@@ -236,6 +248,7 @@ export function listDirectoryGroupEntriesFromMapKeys(params: {
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
}
/** Lists group entries from map keys plus allowFrom-style values. */
export function listDirectoryGroupEntriesFromMapKeysAndAllowFrom(params: {
groups?: Record<string, unknown>;
allowFrom?: readonly unknown[];
@@ -257,6 +270,7 @@ export function listDirectoryGroupEntriesFromMapKeysAndAllowFrom(params: {
return toDirectoryEntries("group", applyDirectoryQueryAndLimit(ids, params));
}
/** Lists user entries from allowFrom values on a required resolved account. */
export function listResolvedDirectoryUserEntriesFromAllowFrom<ResolvedAccount>(
params: DirectoryConfigParams & {
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;
@@ -273,6 +287,7 @@ export function listResolvedDirectoryUserEntriesFromAllowFrom<ResolvedAccount>(
});
}
/** Lists group entries from map keys on a required resolved account. */
export function listResolvedDirectoryGroupEntriesFromMapKeys<ResolvedAccount>(
params: DirectoryConfigParams & {
resolveAccount: (cfg: OpenClawConfig, accountId?: string | null) => ResolvedAccount;

View File

@@ -1,8 +1,13 @@
import type { OpenClawConfig } from "../../config/types.js";
/** Shared params passed to channel directory listers. */
export type DirectoryConfigParams = {
/** Current OpenClaw config snapshot. */
cfg: OpenClawConfig;
/** Optional configured account to list directory entries for. */
accountId?: string | null;
/** Optional case-insensitive filter text. */
query?: string | null;
/** Optional positive maximum number of entries to return. */
limit?: number | null;
};

View File

@@ -3,11 +3,13 @@ import { normalizeStringEntries } from "@openclaw/normalization-core/string-norm
export type ChannelDmAllowFromMode = "topOnly" | "topOrNested" | "nestedOnly";
export type ChannelDmPolicy = "pairing" | "allowlist" | "open" | "disabled";
/** Normalized DM access settings read from channel or account config. */
export type ChannelDmAccess = {
dmPolicy?: ChannelDmPolicy;
allowFrom?: Array<string | number>;
};
/** Mutable config record shape used by DM access migrations. */
export type DmAccessRecord = Record<string, unknown>;
type DmFieldKind = "policy" | "allowFrom";
@@ -22,6 +24,7 @@ export type CompatMutationResult = {
changed: boolean;
};
/** Normalizes user/config DM policy strings to the supported policy set. */
export function normalizeChannelDmPolicy(value: string | undefined): ChannelDmPolicy | undefined {
return value === "pairing" || value === "allowlist" || value === "open" || value === "disabled"
? value
@@ -42,6 +45,8 @@ function cloneDm(entry: DmAccessRecord): DmAccessRecord | null {
function resolveDmFieldPaths(mode: ChannelDmAllowFromMode, kind: DmFieldKind): DmFieldPaths {
const topKey = kind === "policy" ? "dmPolicy" : "allowFrom";
const nestedKey = kind === "policy" ? "policy" : "allowFrom";
// Some channels still store DM config under `dm.*`; keep canonical and legacy
// paths paired so readers and migrations make the same top/nested decision.
if (mode === "nestedOnly") {
return {
canonicalPath: ["dm", nestedKey],
@@ -122,6 +127,10 @@ function readCanonicalOrLegacy(
return readPath(entry, paths.canonicalPath) ?? readPath(entry, paths.legacyPath);
}
/**
* Resolves the effective DM policy from account config, parent config, then an
* optional default while honoring the channel's top/nested storage mode.
*/
export function resolveChannelDmPolicy(params: {
account?: DmAccessRecord | null;
parent?: DmAccessRecord | null;
@@ -136,6 +145,10 @@ export function resolveChannelDmPolicy(params: {
return typeof value === "string" ? normalizeChannelDmPolicy(value) : undefined;
}
/**
* Resolves the effective DM allowlist from account or parent config, accepting
* both canonical and legacy storage paths for the selected mode.
*/
export function resolveChannelDmAllowFrom(params: {
account?: DmAccessRecord | null;
parent?: DmAccessRecord | null;
@@ -148,6 +161,7 @@ export function resolveChannelDmAllowFrom(params: {
return Array.isArray(value) ? (value as Array<string | number>) : undefined;
}
/** Resolves DM policy and allowlist together for runtime authorization. */
export function resolveChannelDmAccess(params: {
account?: DmAccessRecord | null;
parent?: DmAccessRecord | null;
@@ -160,6 +174,10 @@ export function resolveChannelDmAccess(params: {
};
}
/**
* Writes an allowlist to the canonical DM path and removes the legacy path when
* present, recording user-facing doctor/setup change notes.
*/
export function setCanonicalDmAllowFrom(params: {
entry: DmAccessRecord;
mode: ChannelDmAllowFromMode;
@@ -178,6 +196,10 @@ export function setCanonicalDmAllowFrom(params: {
params.changes?.push(`- ${formatPath(params.pathPrefix, paths.canonicalPath)}: ${params.reason}`);
}
/**
* Migrates legacy `dm.policy` / `dm.allowFrom` aliases to canonical fields and
* returns a cloned entry when any compatibility cleanup was needed.
*/
export function normalizeLegacyDmAliases(params: {
entry: DmAccessRecord;
pathPrefix: string;
@@ -294,6 +316,8 @@ export function ensureOpenDmPolicyAllowFromWildcard(params: {
? (legacyAllowFrom as Array<string | number>)
: undefined;
// `dmPolicy="open"` is represented by a wildcard allowlist in the canonical
// path so downstream allowlist-only checks do not need a second policy branch.
if (hasWildcard(sourceAllowFrom)) {
if (canonicalAllowFrom === undefined && sourceAllowFrom) {
setCanonicalDmAllowFrom({

View File

@@ -36,6 +36,7 @@ function loadBundledChannelPublicArtifact(
return undefined;
}
/** Loads the bundled channel doctor contract artifact used before full plugin startup. */
export function loadBundledChannelDoctorContractApi(
channelId: string,
): BundledChannelDoctorContractApi | undefined {

View File

@@ -3,6 +3,7 @@ import type { OpenClawConfig } from "../../config/types.openclaw.js";
import { hasActiveApprovalNativeRouteRuntime } from "../../infra/approval-native-route-coordinator.js";
import { getChannelPlugin, normalizeChannelId } from "./registry.js";
/** Lets a channel suppress the generic local exec-approval prompt when native UI owns it. */
export function shouldSuppressLocalExecApprovalPrompt(params: {
channel?: string | null;
cfg: OpenClawConfig;
@@ -21,6 +22,8 @@ export function shouldSuppressLocalExecApprovalPrompt(params: {
hint: {
kind: "approval-pending",
approvalKind: "exec",
// Native route state is host-owned; channels use this as a hint, not
// as authorization, when deciding whether to hide duplicate prompts.
nativeRouteActive: hasActiveApprovalNativeRouteRuntime({
channel,
accountId: params.accountId,

View File

@@ -1,5 +1,9 @@
import type { ChannelMeta } from "./types.core.js";
/**
* Resolves modern `exposure` flags with the older top-level visibility flags
* kept as fallback input for existing channel metadata.
*/
export function resolveChannelExposure(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
) {
@@ -10,12 +14,14 @@ export function resolveChannelExposure(
};
}
/** Returns whether a channel should appear in configured-channel lists. */
export function isChannelVisibleInConfiguredLists(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
): boolean {
return resolveChannelExposure(meta).configured;
}
/** Returns whether a channel should appear in setup/onboarding choices. */
export function isChannelVisibleInSetup(
meta: Pick<ChannelMeta, "exposure" | "showConfigured" | "showInSetup">,
): boolean {

View File

@@ -22,6 +22,7 @@ function loadBundledChannelGatewayAuthApi(channelId: string): GatewayAuthBypassA
}
}
/** Loads bundled channel-declared gateway auth bypass paths from its public artifact. */
export function resolveBundledChannelGatewayAuthBypassPaths(params: {
channelId: string;
cfg: OpenClawConfig;

View File

@@ -16,12 +16,14 @@ type ConfigGroupPolicyWarningCollector<Params extends { cfg: OpenClawConfig }> =
) => string[];
type WarningCollector<Params> = (params: Params) => string[];
/** Combines optional warning collectors while preserving warning order. */
export function composeWarningCollectors<Params>(
...collectors: Array<WarningCollector<Params> | null | undefined>
): WarningCollector<Params> {
return (params) => collectors.flatMap((collector) => collector?.(params) ?? []);
}
/** Adapts a warning collector to a larger caller param shape. */
export function projectWarningCollector<Params, Projected>(
project: (params: Params) => Projected,
collector: WarningCollector<Projected>,
@@ -29,12 +31,14 @@ export function projectWarningCollector<Params, Projected>(
return (params) => collector(project(params));
}
/** Adapts a config-only warning collector to callers that carry extra params. */
export function projectConfigWarningCollector<Params extends { cfg: OpenClawConfig }>(
collector: WarningCollector<{ cfg: OpenClawConfig }>,
): WarningCollector<Params> {
return projectWarningCollector((params) => ({ cfg: params.cfg }), collector);
}
/** Adapts a config/account-id warning collector to richer runtime params. */
export function projectConfigAccountIdWarningCollector<
Params extends { cfg: OpenClawConfig; accountId?: string | null },
>(
@@ -46,6 +50,7 @@ export function projectConfigAccountIdWarningCollector<
);
}
/** Adapts an account-only warning collector to params that wrap the account. */
export function projectAccountWarningCollector<
ResolvedAccount,
Params extends { account: ResolvedAccount },
@@ -53,6 +58,7 @@ export function projectAccountWarningCollector<
return projectWarningCollector((params) => params.account, collector);
}
/** Projects config before running an account+config warning collector. */
export function projectAccountConfigWarningCollector<
ResolvedAccount,
ProjectedCfg,
@@ -67,6 +73,7 @@ export function projectAccountConfigWarningCollector<
);
}
/** Builds a collector from predicates that may return one warning, many, or none. */
export function createConditionalWarningCollector<Params>(
...collectors: Array<(params: Params) => string | string[] | null | undefined | false>
): WarningCollector<Params> {
@@ -80,6 +87,7 @@ export function createConditionalWarningCollector<Params>(
});
}
/** Appends account-only conditional warnings after a base collector. */
export function composeAccountWarningCollectors<
ResolvedAccount,
Params extends { account: ResolvedAccount },
@@ -99,6 +107,7 @@ export function composeAccountWarningCollectors<
);
}
/** Builds the canonical wording for groupPolicy="open" warnings. */
export function buildOpenGroupPolicyWarning(params: {
surface: string;
openBehavior: string;
@@ -107,6 +116,7 @@ export function buildOpenGroupPolicyWarning(params: {
return `- ${params.surface}: groupPolicy="open" ${params.openBehavior}. ${params.remediation}.`;
}
/** Warns when open group policy allows broad sender access. */
export function buildOpenGroupPolicyRestrictSendersWarning(params: {
surface: string;
openScope: string;
@@ -122,6 +132,7 @@ export function buildOpenGroupPolicyRestrictSendersWarning(params: {
});
}
/** Warns when open group policy combines with a missing route allowlist. */
export function buildOpenGroupPolicyNoRouteAllowlistWarning(params: {
surface: string;
routeAllowlistPath: string;
@@ -138,6 +149,7 @@ export function buildOpenGroupPolicyNoRouteAllowlistWarning(params: {
});
}
/** Warns when open group policy should be paired with a route allowlist. */
export function buildOpenGroupPolicyConfigureRouteAllowlistWarning(params: {
surface: string;
openScope: string;
@@ -153,6 +165,7 @@ export function buildOpenGroupPolicyConfigureRouteAllowlistWarning(params: {
});
}
/** Emits the restrict-senders warning only for the open group-policy state. */
export function collectOpenGroupPolicyRestrictSendersWarnings(
params: Parameters<typeof buildOpenGroupPolicyRestrictSendersWarning>[0] & {
groupPolicy: "open" | "allowlist" | "disabled";
@@ -164,6 +177,7 @@ export function collectOpenGroupPolicyRestrictSendersWarnings(
return [buildOpenGroupPolicyRestrictSendersWarning(params)];
}
/** Resolves allowlist-provider runtime policy before collecting sender warnings. */
export function collectAllowlistProviderRestrictSendersWarnings(
params: {
cfg: OpenClawConfig;
@@ -231,6 +245,7 @@ export function createOpenGroupPolicyRestrictSendersWarningCollector<ResolvedAcc
});
}
/** Runs a collector after applying allowlist-provider default policy semantics. */
export function collectAllowlistProviderGroupPolicyWarnings(params: {
cfg: OpenClawConfig;
providerConfigPresent: boolean;
@@ -263,6 +278,7 @@ export function createAllowlistProviderGroupPolicyWarningCollector<
});
}
/** Runs a collector after applying open-provider default policy semantics. */
export function collectOpenProviderGroupPolicyWarnings(params: {
cfg: OpenClawConfig;
providerConfigPresent: boolean;
@@ -310,6 +326,7 @@ export function createAllowlistProviderOpenWarningCollector<ResolvedAccount>(par
});
}
/** Chooses the open-policy warning based on whether a route allowlist exists. */
export function collectOpenGroupPolicyRouteAllowlistWarnings(params: {
groupPolicy: "open" | "allowlist" | "disabled";
routeAllowlistConfigured: boolean;
@@ -347,6 +364,7 @@ export function createAllowlistProviderRouteAllowlistWarningCollector<ResolvedAc
});
}
/** Chooses the open-policy warning for providers with configurable route allowlists. */
export function collectOpenGroupPolicyConfiguredRouteWarnings(params: {
groupPolicy: "open" | "allowlist" | "disabled";
routeAllowlistConfigured: boolean;

View File

@@ -5,7 +5,11 @@ import { DEFAULT_ACCOUNT_ID } from "../../routing/session-key.js";
import type { ChannelSecurityDmPolicy } from "./types.core.js";
import type { ChannelPlugin } from "./types.plugin.js";
// Channel docking helper: use this when selecting the default account for a plugin.
/**
* Resolves the account id a channel should use when a caller did not provide
* one, honoring plugin-specific defaults before falling back to the first
* configured account and then the shared default account.
*/
export function resolveChannelDefaultAccountId<ResolvedAccount>(params: {
plugin: ChannelPlugin<ResolvedAccount>;
cfg: OpenClawConfig;
@@ -15,12 +19,14 @@ export function resolveChannelDefaultAccountId<ResolvedAccount>(params: {
return params.plugin.config.defaultAccountId?.(params.cfg) ?? accountIds[0] ?? DEFAULT_ACCOUNT_ID;
}
/** Formats the CLI commands needed to approve a pending pairing request. */
export function formatPairingApproveHint(channelId: string): string {
const listCmd = formatCliCommand(`openclaw pairing list ${channelId}`);
const approveCmd = formatCliCommand(`openclaw pairing approve ${channelId} <code>`);
return `Approve via: ${listCmd} / ${approveCmd}`;
}
/** Splits optional comma, semicolon, or newline-delimited config values. */
export function parseOptionalDelimitedEntries(value?: string): string[] | undefined {
if (!value?.trim()) {
return undefined;
@@ -29,6 +35,10 @@ export function parseOptionalDelimitedEntries(value?: string): string[] | undefi
return parsed.length > 0 ? parsed : undefined;
}
/**
* Builds a DM security policy for channel/account scoped allowlists while
* reporting the config path a user should edit.
*/
export function buildAccountScopedDmSecurityPolicy(params: {
cfg: OpenClawConfig;
channelKey: string;
@@ -68,6 +78,8 @@ export function buildAccountScopedDmSecurityPolicy(params: {
config: Record<string, unknown> | undefined,
fields: Array<string | null>,
) => fields.some((field) => field != null && config?.[field] !== undefined);
// Prefer the narrowest config path that already carries either policy field;
// otherwise report the account path when that account exists, then root.
const basePath =
simplePolicyField || simpleAllowFromField
? matchesAnyField(accountConfig, [simplePolicyField, simpleAllowFromField])

View File

@@ -70,6 +70,7 @@ function collectRelevantChannelIdsForTouchedPaths(params: {
return filteredChannelIds.filter((channelId) => touchedChannelIds.has(channelId));
}
/** Collects channel-owned legacy config rules relevant to the current raw config/touched paths. */
export function collectChannelLegacyConfigRules(
raw?: unknown,
touchedPaths?: ReadonlyArray<ReadonlyArray<string>>,
@@ -112,6 +113,8 @@ export function collectChannelLegacyConfigRules(
const seen = new Set<string>();
return rules.filter((rule) => {
// Touched-path filtering runs after all sources load so duplicate rules
// from bundled artifacts, bootstrap plugins, and installed plugins collapse consistently.
if (!shouldIncludeLegacyRuleForTouchedPaths(rule.path, touchedPaths)) {
return false;
}

View File

@@ -3,6 +3,10 @@ import { normalizeAccountId } from "../../routing/session-key.js";
const MB = 1024 * 1024;
/**
* Resolves the effective channel media byte limit from channel-specific account
* config first, then the global agent default.
*/
export function resolveChannelMediaMaxBytes(params: {
cfg: OpenClawConfig;
// Channel-specific config lives under different keys; keep this helper generic

View File

@@ -1,8 +1,11 @@
export type MediaPayloadInput = {
/** Local path or URL-like value passed through to legacy media fields. */
path: string;
/** Optional MIME type paired with this media entry. */
contentType?: string;
};
/** Legacy media payload fields consumed by older channel/plugin adapters. */
export type MediaPayload = {
MediaPath?: string;
MediaType?: string;
@@ -12,6 +15,10 @@ export type MediaPayload = {
MediaTypes?: string[];
};
/**
* Builds the legacy single-item and multi-item media payload fields from a
* normalized media list.
*/
export function buildMediaPayload(
mediaList: MediaPayloadInput[],
opts?: { preserveMediaTypeCardinality?: boolean },
@@ -19,6 +26,8 @@ export function buildMediaPayload(
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const rawMediaTypes = mediaList.map((media) => media.contentType ?? "");
// Some adapters need `MediaTypes` length to match `MediaPaths`; others expect
// omitted blanks to behave like the older sparse media payload shape.
const mediaTypes = opts?.preserveMediaTypeCardinality
? rawMediaTypes
: rawMediaTypes.filter((value): value is string => Boolean(value));

View File

@@ -43,10 +43,12 @@ type ChannelMessageToolMediaSourceParamKeyInput = ChannelMessageActionDiscoveryP
const loggedMessageActionErrors = new Set<string>();
/** Normalizes channel ids for message-action discovery, preserving unknown raw ids. */
export function resolveMessageActionDiscoveryChannelId(raw?: string | null): string | undefined {
return normalizeAnyChannelId(raw) ?? normalizeOptionalString(raw);
}
/** Builds the context object passed to channel message-tool discovery adapters. */
export function createMessageActionDiscoveryContext(
params: ChannelMessageActionDiscoveryInput,
): ChannelMessageActionDiscoveryContext {
@@ -144,6 +146,7 @@ function normalizeMessageToolMediaSourceParams(
);
}
/** Resolves the current channel's message-tool discovery adapter from loaded, bundled, or registry state. */
export function resolveCurrentChannelMessageToolDiscoveryAdapter(channel?: string | null): {
pluginId: string;
actions: ChannelMessageToolDiscoveryAdapter;
@@ -176,6 +179,7 @@ export function resolveCurrentChannelMessageToolDiscoveryAdapter(channel?: strin
};
}
/** Safely resolves message actions, capabilities, schema, and media params for one plugin. */
export function resolveMessageActionDiscoveryForPlugin(params: {
pluginId: string;
actions?: ChannelMessageToolDiscoveryAdapter;
@@ -217,6 +221,7 @@ export function resolveMessageActionDiscoveryForPlugin(params: {
};
}
/** Lists all known message actions, including built-ins and plugin-declared actions. */
export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageActionName[] {
const actions = new Set<ChannelMessageActionName>(["send", "broadcast"]);
for (const plugin of listChannelPlugins()) {
@@ -232,6 +237,7 @@ export function listChannelMessageActions(cfg: OpenClawConfig): ChannelMessageAc
return Array.from(actions);
}
/** Lists actions safe for cross-channel use after removing current-channel-only schema actions. */
export function listCrossChannelSchemaSupportedMessageActions(
params: ChannelMessageActionDiscoveryParams & {
channel?: string;
@@ -258,6 +264,7 @@ export function listCrossChannelSchemaSupportedMessageActions(
continue;
}
if (!Object.hasOwn(contribution, "actions")) {
// Unscoped current-channel schema may depend on ambient channel context, so be conservative.
return [];
}
const actions = contribution.actions;
@@ -274,6 +281,7 @@ export function listCrossChannelSchemaSupportedMessageActions(
return resolved.actions.filter((action) => !schemaBlockedActions.has(action));
}
/** Lists all message capabilities declared by loaded channel plugins. */
export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMessageCapability[] {
const capabilities = new Set<ChannelMessageCapability>();
for (const plugin of listChannelPlugins()) {
@@ -289,6 +297,7 @@ export function listChannelMessageCapabilities(cfg: OpenClawConfig): ChannelMess
return Array.from(capabilities);
}
/** Lists message capabilities for one normalized channel. */
export function listChannelMessageCapabilitiesForChannel(
params: ChannelMessageActionDiscoveryParams,
): ChannelMessageCapability[] {
@@ -320,6 +329,7 @@ function mergeToolSchemaProperties(
}
}
/** Resolves merged message-tool schema properties visible for the current channel context. */
export function resolveChannelMessageToolSchemaProperties(
params: ChannelMessageActionDiscoveryParams,
): Record<string, TSchema> {
@@ -369,6 +379,7 @@ export function resolveChannelMessageToolSchemaProperties(
return properties;
}
/** Resolves media-source parameter names for a channel/action pair. */
export function resolveChannelMessageToolMediaSourceParamKeys(
params: ChannelMessageToolMediaSourceParamKeyInput,
): string[] {
@@ -386,6 +397,7 @@ export function resolveChannelMessageToolMediaSourceParamKeys(
return uniqueStrings(described.mediaSourceParams);
}
/** Returns whether any loaded channel advertises the requested message capability. */
export function channelSupportsMessageCapability(
cfg: OpenClawConfig,
capability: ChannelMessageCapability,
@@ -393,6 +405,7 @@ export function channelSupportsMessageCapability(
return listChannelMessageCapabilities(cfg).includes(capability);
}
/** Returns whether one channel advertises the requested message capability. */
export function channelSupportsMessageCapabilityForChannel(
params: ChannelMessageActionDiscoveryParams,
capability: ChannelMessageCapability,

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