mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 14:01:24 +08:00
Compare commits
512 Commits
v2026.6.1
...
codex/mark
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c535f6d3f3 | ||
|
|
baaf6f8d1c | ||
|
|
01e7733deb | ||
|
|
66425c3406 | ||
|
|
f6d05d604b | ||
|
|
92a676fc71 | ||
|
|
4e5835c038 | ||
|
|
c4686e50c2 | ||
|
|
3764ff6b84 | ||
|
|
242995a3af | ||
|
|
4ce258ae9b | ||
|
|
b132ca0183 | ||
|
|
4c556fc09f | ||
|
|
98e05f8754 | ||
|
|
05df67dd70 | ||
|
|
30d6a53681 | ||
|
|
3c25345fd5 | ||
|
|
11d7a51844 | ||
|
|
38da14ac55 | ||
|
|
eb7ec0e620 | ||
|
|
9fe0862e4b | ||
|
|
4667b7cca2 | ||
|
|
a8b695a944 | ||
|
|
ba6af56f48 | ||
|
|
412fb4b32e | ||
|
|
6fa07e83bd | ||
|
|
889fc5fa91 | ||
|
|
9bfb81d64e | ||
|
|
08b953d111 | ||
|
|
d10427f45c | ||
|
|
c6a49588aa | ||
|
|
187cfdf385 | ||
|
|
724bdbb1bd | ||
|
|
5a6a6db65d | ||
|
|
daf2b631e0 | ||
|
|
ba9993229f | ||
|
|
021252e214 | ||
|
|
5c00de15f5 | ||
|
|
ba97b0484d | ||
|
|
e2c5e19876 | ||
|
|
b3d9bf8f55 | ||
|
|
59e8c8a166 | ||
|
|
a8019540bd | ||
|
|
55a44bb7ae | ||
|
|
2821654f38 | ||
|
|
22855ab94e | ||
|
|
10eec59169 | ||
|
|
affa47c13b | ||
|
|
d39becd739 | ||
|
|
eb8a1b6877 | ||
|
|
f3608d08b4 | ||
|
|
e6dec97e75 | ||
|
|
80e2bfbd16 | ||
|
|
a5c8558689 | ||
|
|
aca296e92b | ||
|
|
dba817386a | ||
|
|
f8d93befac | ||
|
|
887ebc95fa | ||
|
|
54ebb9d08f | ||
|
|
ce4f471206 | ||
|
|
ae606118b4 | ||
|
|
30c0c1352f | ||
|
|
94a92a78f4 | ||
|
|
e4539d2756 | ||
|
|
42f1c9e3d4 | ||
|
|
ade91600bc | ||
|
|
5fe49e3f9d | ||
|
|
518eff785e | ||
|
|
f1635142d8 | ||
|
|
ee39f5d282 | ||
|
|
17b35107a9 | ||
|
|
3d798f4e8e | ||
|
|
81dcefe261 | ||
|
|
c847c89dbb | ||
|
|
690d79b32a | ||
|
|
593d97b9ca | ||
|
|
5e88b8b5af | ||
|
|
c57c27016a | ||
|
|
1f0c7847a6 | ||
|
|
8a475b6631 | ||
|
|
d48a8e53bb | ||
|
|
845851cc78 | ||
|
|
03abdfea2c | ||
|
|
eb16425492 | ||
|
|
64fcdba480 | ||
|
|
ca0e791b4a | ||
|
|
76b69cfecb | ||
|
|
080b453592 | ||
|
|
fa4f5044f5 | ||
|
|
e144252720 | ||
|
|
646522aaa3 | ||
|
|
10c39f6da5 | ||
|
|
c07ae4e067 | ||
|
|
db0d6d750f | ||
|
|
e51e9c327c | ||
|
|
c057a31564 | ||
|
|
59cb1a2be3 | ||
|
|
ece65f4e24 | ||
|
|
266380f6c0 | ||
|
|
84914e4dc8 | ||
|
|
5f51677454 | ||
|
|
39fa88a1e4 | ||
|
|
8a42725d38 | ||
|
|
ed4f308d28 | ||
|
|
e58cba2797 | ||
|
|
088d228e71 | ||
|
|
cb55aa2ab1 | ||
|
|
1705b12dea | ||
|
|
a605c11b6f | ||
|
|
d31966726b | ||
|
|
9e45e0c9b6 | ||
|
|
c9f51ad18d | ||
|
|
a5b42a7500 | ||
|
|
0cff3edb56 | ||
|
|
fb756242be | ||
|
|
fe384065fe | ||
|
|
a8bbff2f9e | ||
|
|
cceb080869 | ||
|
|
e87e873017 | ||
|
|
7764e91417 | ||
|
|
535616c292 | ||
|
|
62eb7259c3 | ||
|
|
e5d7cf2efc | ||
|
|
ed43f9090d | ||
|
|
e634c7459e | ||
|
|
2ad26392c5 | ||
|
|
378146c9bc | ||
|
|
d67ff3e041 | ||
|
|
db2df9dd79 | ||
|
|
d43ba91710 | ||
|
|
d6145ad4c2 | ||
|
|
0c3b71ba23 | ||
|
|
d58a649a33 | ||
|
|
c41b710ae9 | ||
|
|
8c3e7eddfd | ||
|
|
09d8eae1e2 | ||
|
|
786d5c1042 | ||
|
|
fd88ce0039 | ||
|
|
b487a2dfbb | ||
|
|
1802ed180a | ||
|
|
0c790251e1 | ||
|
|
52ad1b26ef | ||
|
|
88cefa4d3f | ||
|
|
ef6e5aa961 | ||
|
|
2c04aea604 | ||
|
|
1cb93fee3e | ||
|
|
247a67320a | ||
|
|
02aee615de | ||
|
|
ba7e68b271 | ||
|
|
f3b723fd9a | ||
|
|
58a5c1e512 | ||
|
|
43212e574c | ||
|
|
95bd60001d | ||
|
|
ac206252fa | ||
|
|
7ad843234f | ||
|
|
e52c366a07 | ||
|
|
681a0863f1 | ||
|
|
ee23d27ce2 | ||
|
|
175db3e84d | ||
|
|
07693abbca | ||
|
|
27359abe70 | ||
|
|
41ea42f864 | ||
|
|
30f3fd75b1 | ||
|
|
4518c7f673 | ||
|
|
39b93679b5 | ||
|
|
8eb8eef88e | ||
|
|
8b02c78f46 | ||
|
|
a181224d0a | ||
|
|
ef8f96aeca | ||
|
|
5f143b6361 | ||
|
|
db4fb64e2f | ||
|
|
23426e4d26 | ||
|
|
6fd7ffd4c4 | ||
|
|
962cae0bf9 | ||
|
|
c2364779e0 | ||
|
|
a17b95e2dc | ||
|
|
2987e9bc82 | ||
|
|
9011a31d56 | ||
|
|
55f124ed01 | ||
|
|
2a42a0e2fe | ||
|
|
6a8090b7d8 | ||
|
|
88c1abb9b5 | ||
|
|
a9f3e35813 | ||
|
|
39b3364ae5 | ||
|
|
a9176b3e3c | ||
|
|
df4512571f | ||
|
|
ba95ba46da | ||
|
|
30678b2812 | ||
|
|
6477e3c75a | ||
|
|
69763d0d0e | ||
|
|
bd3683052d | ||
|
|
061cddc829 | ||
|
|
112a78b070 | ||
|
|
c665944276 | ||
|
|
fd883d2eb4 | ||
|
|
69583e9f15 | ||
|
|
6234092a66 | ||
|
|
d69a72f98e | ||
|
|
bfaaac79b6 | ||
|
|
bc36755609 | ||
|
|
a1223825a2 | ||
|
|
c05687aa34 | ||
|
|
d032288a77 | ||
|
|
7e71a0b4a4 | ||
|
|
187dd18674 | ||
|
|
f2e6163788 | ||
|
|
f54ee04c05 | ||
|
|
d2b8293236 | ||
|
|
10c99178c6 | ||
|
|
9fdc022ad0 | ||
|
|
bbbb3ad27b | ||
|
|
824abf5fa1 | ||
|
|
3de4e9e00f | ||
|
|
bda364fb74 | ||
|
|
0785082b8d | ||
|
|
80dd8c390e | ||
|
|
df637ed2f8 | ||
|
|
69b1b3fdd3 | ||
|
|
6ae61ffaef | ||
|
|
8b9b4ce082 | ||
|
|
75223a869d | ||
|
|
e3514e8d71 | ||
|
|
4088a58674 | ||
|
|
ae3f41f6c3 | ||
|
|
52e1d14e94 | ||
|
|
0b41911c70 | ||
|
|
c3fa7f2148 | ||
|
|
e20d87cfc3 | ||
|
|
fb94dac19d | ||
|
|
c959f82d5c | ||
|
|
8502427352 | ||
|
|
853e32fef3 | ||
|
|
bbee5e456c | ||
|
|
e4e3a8dbc4 | ||
|
|
596ee3c2a8 | ||
|
|
e836cd8b71 | ||
|
|
3f313b0ca9 | ||
|
|
7759b44638 | ||
|
|
fa3e1067a6 | ||
|
|
95eaf32b61 | ||
|
|
902c2d685c | ||
|
|
e5d1ce4f84 | ||
|
|
1f6058f495 | ||
|
|
95a84d98e4 | ||
|
|
3534f68068 | ||
|
|
9d71225d39 | ||
|
|
02043fe89b | ||
|
|
32abf56791 | ||
|
|
750bbdf09f | ||
|
|
afe95da1f7 | ||
|
|
da42fb0a81 | ||
|
|
ba65ce48a0 | ||
|
|
d7a35e7079 | ||
|
|
8016ce9999 | ||
|
|
1f9a80ca61 | ||
|
|
a21a7ee883 | ||
|
|
1d0f43a709 | ||
|
|
f1ecfbe08f | ||
|
|
301c84204d | ||
|
|
ca78f99c96 | ||
|
|
1d4f70a8cd | ||
|
|
a8113c72f6 | ||
|
|
4f48cd1413 | ||
|
|
843dfafaa8 | ||
|
|
0db557a6dc | ||
|
|
d598a239ca | ||
|
|
037cf3ed86 | ||
|
|
89203a47dd | ||
|
|
0160c650e6 | ||
|
|
d92e91373c | ||
|
|
0f4eedd32a | ||
|
|
a1a836f2bb | ||
|
|
c4a8e1be9b | ||
|
|
7a070e6ca2 | ||
|
|
904f84df05 | ||
|
|
fbb050028d | ||
|
|
fb78550cbb | ||
|
|
96e9d73a64 | ||
|
|
365b63de19 | ||
|
|
410bf91087 | ||
|
|
4d9d9d3e42 | ||
|
|
c1d56cb9b3 | ||
|
|
6b8fd7a3cd | ||
|
|
5f9926b7fd | ||
|
|
4becd8dbfe | ||
|
|
a8a2be4f33 | ||
|
|
d688f72752 | ||
|
|
19d0073e5f | ||
|
|
74eacd9742 | ||
|
|
22518f9820 | ||
|
|
3f04d320ad | ||
|
|
31420c16e1 | ||
|
|
4276ba3b60 | ||
|
|
a7b2cd5be2 | ||
|
|
1cf7ea66e5 | ||
|
|
97026eab56 | ||
|
|
6fa4e7ceb0 | ||
|
|
1fe2d34e01 | ||
|
|
2751480168 | ||
|
|
930b1fc082 | ||
|
|
ba9825795b | ||
|
|
3f5bf3ac35 | ||
|
|
8197cdcac4 | ||
|
|
38306a7695 | ||
|
|
fd7b7a09d8 | ||
|
|
1752e50eb1 | ||
|
|
92138702fb | ||
|
|
934bf883c1 | ||
|
|
42b0b53efa | ||
|
|
6d478c61cf | ||
|
|
4a48b7efe7 | ||
|
|
b51b9cbbf4 | ||
|
|
fa568259e4 | ||
|
|
1e26fa770d | ||
|
|
3693916c0c | ||
|
|
5b313c819a | ||
|
|
cafea5c3ef | ||
|
|
ab6bc8d109 | ||
|
|
db0470aece | ||
|
|
cf49d56b74 | ||
|
|
2eeabc4e12 | ||
|
|
f0af33a0ff | ||
|
|
20133a58a9 | ||
|
|
2a69d62245 | ||
|
|
14440032bd | ||
|
|
ea516f648b | ||
|
|
b71792767e | ||
|
|
7939c408cf | ||
|
|
d017bacc5a | ||
|
|
86ad6d9772 | ||
|
|
0dcb3ce86b | ||
|
|
a864715dd0 | ||
|
|
474cdce26c | ||
|
|
5917d8ba45 | ||
|
|
1bc4ba9908 | ||
|
|
6f16ee9266 | ||
|
|
2c7c7bf7f9 | ||
|
|
7580daf705 | ||
|
|
e6c50fd771 | ||
|
|
f3aae8a380 | ||
|
|
34c5d059aa | ||
|
|
eab3b1a6a2 | ||
|
|
0edb913c13 | ||
|
|
74d98e1fd7 | ||
|
|
082c443015 | ||
|
|
8340b1151c | ||
|
|
89daadd478 | ||
|
|
3d335e402a | ||
|
|
8b5a6bda51 | ||
|
|
3ac62666ed | ||
|
|
8d5a2f5fa9 | ||
|
|
0b5ead9f37 | ||
|
|
67e6f9aaba | ||
|
|
983c5a664c | ||
|
|
b5ee774d68 | ||
|
|
9676536668 | ||
|
|
441a7cf792 | ||
|
|
3a35c1e806 | ||
|
|
fbeaf41dc2 | ||
|
|
5590a45e7e | ||
|
|
9a551d49f3 | ||
|
|
fdae22dfea | ||
|
|
552fa03822 | ||
|
|
7e97b42a95 | ||
|
|
d3b9c5aa3e | ||
|
|
78172b720b | ||
|
|
9ea00cf73a | ||
|
|
a5013c5574 | ||
|
|
ec7ae4fc9a | ||
|
|
a9e6e4c5e3 | ||
|
|
92e6368860 | ||
|
|
6757a52944 | ||
|
|
e011559750 | ||
|
|
4d63f1ea8c | ||
|
|
36d1080d83 | ||
|
|
d3e8a89959 | ||
|
|
8b3c5d898a | ||
|
|
0c5b962a29 | ||
|
|
1a43a00def | ||
|
|
439904eef4 | ||
|
|
9bd5808fda | ||
|
|
32bf8712e9 | ||
|
|
2466798a08 | ||
|
|
797e503fe8 | ||
|
|
c7e238a862 | ||
|
|
0097a6fb46 | ||
|
|
8a3bda61e1 | ||
|
|
fd715e0eee | ||
|
|
f83ff78bb8 | ||
|
|
9094429658 | ||
|
|
909d521602 | ||
|
|
44cef2a792 | ||
|
|
0d4dec734d | ||
|
|
41aee0429c | ||
|
|
e1f1045d46 | ||
|
|
a98b9ceb37 | ||
|
|
b6064d1cf5 | ||
|
|
fbbf2e6237 | ||
|
|
2f2c77e192 | ||
|
|
d64c80daae | ||
|
|
db9ced7b9d | ||
|
|
11576303ab | ||
|
|
c2f5594555 | ||
|
|
df1e4177e4 | ||
|
|
a7d11dd3c7 | ||
|
|
493e4ab2f9 | ||
|
|
0f4fa29d78 | ||
|
|
f25cbad91b | ||
|
|
713d4cd355 | ||
|
|
2ed5feffef | ||
|
|
99d6f0f8c1 | ||
|
|
dcbe7e30d9 | ||
|
|
917d24f5c9 | ||
|
|
be922af1e6 | ||
|
|
bc3165647f | ||
|
|
9f36c0f00c | ||
|
|
35ee75ec6b | ||
|
|
6a14ad3189 | ||
|
|
3451c03366 | ||
|
|
5b3d73bc90 | ||
|
|
97620910ef | ||
|
|
e58cb30c44 | ||
|
|
158dd20e24 | ||
|
|
16d872f02d | ||
|
|
3edc65d397 | ||
|
|
86260867ad | ||
|
|
dd062c655c | ||
|
|
b8244deddb | ||
|
|
b1fa7f0e16 | ||
|
|
eed3735edd | ||
|
|
9d1edb4c00 | ||
|
|
d39c2051d0 | ||
|
|
d008a425c2 | ||
|
|
7fc4dd9d14 | ||
|
|
c3697c2ac1 | ||
|
|
de85fcd978 | ||
|
|
8fb987c565 | ||
|
|
8cacdce95e | ||
|
|
fd12d434ba | ||
|
|
a70b17e5cb | ||
|
|
283dff0c19 | ||
|
|
e2878dcf33 | ||
|
|
af9f15074f | ||
|
|
e7e7e4f2f1 | ||
|
|
0ba732cf5e | ||
|
|
a9120d2df6 | ||
|
|
d3106d2209 | ||
|
|
e174ddaaeb | ||
|
|
8b3a9a5617 | ||
|
|
c6fed61806 | ||
|
|
602a8e2d10 | ||
|
|
1c31afac81 | ||
|
|
5ceb45d38e | ||
|
|
ff326e9ca5 | ||
|
|
2991ae6fc9 | ||
|
|
4f12fa1d70 | ||
|
|
a73f42096e | ||
|
|
c11a3a0d78 | ||
|
|
c4520714c8 | ||
|
|
157e893b77 | ||
|
|
f260f1bc06 | ||
|
|
8cba61f985 | ||
|
|
48f2eef53b | ||
|
|
692dbb7b3f | ||
|
|
fc2e6ab07e | ||
|
|
00465096ce | ||
|
|
7ee37a45c4 | ||
|
|
102f1427e9 | ||
|
|
bf14891ff3 | ||
|
|
fc84fd8f26 | ||
|
|
6a10a55114 | ||
|
|
6e1e89cbe9 | ||
|
|
df725c5b4e | ||
|
|
0c7f9ea6be | ||
|
|
216a2daf23 | ||
|
|
a48823f18b | ||
|
|
c4d88ffc3e | ||
|
|
02e12555bb | ||
|
|
9888974144 | ||
|
|
c78717b229 | ||
|
|
d5d0090865 | ||
|
|
f407e71101 | ||
|
|
5d713e20ec | ||
|
|
c291eb6c6c | ||
|
|
36c53e66ef | ||
|
|
6810dfd575 | ||
|
|
9b40fcd056 | ||
|
|
f529df5b97 | ||
|
|
f9c86a65a6 | ||
|
|
e47f45e322 | ||
|
|
7ef4d676c9 | ||
|
|
0c2dc54eae | ||
|
|
b21b889017 | ||
|
|
6991205bd8 | ||
|
|
dfdcd2aa97 | ||
|
|
dc0cc1b7c1 | ||
|
|
25ce9fbb31 | ||
|
|
ca6fd41b95 | ||
|
|
6a6930983b | ||
|
|
a1ff03b634 | ||
|
|
4d8686c24e | ||
|
|
5802610280 | ||
|
|
51c0ca2aa6 | ||
|
|
f3a313bfd1 | ||
|
|
afa810271a | ||
|
|
11d9b2780b | ||
|
|
4a2ce15e59 | ||
|
|
c1e7449b28 | ||
|
|
49156048f0 | ||
|
|
1c212ee73f | ||
|
|
63625093a1 | ||
|
|
fb59ac217c |
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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 ?? "";
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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]);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 [];
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
/** Table rendering modes shared by markdown parsing and table conversion helpers. */
|
||||
export type MarkdownTableMode = "off" | "bullets" | "code" | "block";
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") }
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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[],
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}`;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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}` : "";
|
||||
|
||||
@@ -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 &&
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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>;
|
||||
};
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
) {
|
||||
|
||||
@@ -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];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 () => [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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])
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user