openclaw-app 1.1.8 → 1.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -3
- package/index.js +1377 -0
- package/index.ts +471 -268
- package/openclaw.plugin.json +1 -1
- package/package.json +5 -2
package/index.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import * as path from "path";
|
|
1
3
|
/**
|
|
2
4
|
* OpenClaw App Channel Plugin
|
|
3
5
|
*
|
|
@@ -58,39 +60,124 @@ async function e2eInit(state: E2EState): Promise<string> {
|
|
|
58
60
|
state.localKeyPair = await crypto.subtle.generateKey(
|
|
59
61
|
algo.gen,
|
|
60
62
|
true,
|
|
61
|
-
["deriveKey"]
|
|
63
|
+
["deriveKey", "deriveBits"]
|
|
62
64
|
);
|
|
63
65
|
const pubKeyRaw = await crypto.subtle.exportKey("raw", state.localKeyPair.publicKey);
|
|
64
66
|
return bufToBase64Url(pubKeyRaw);
|
|
65
67
|
}
|
|
66
68
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
+
// ── Persistent E2E Store (Per Account) ──────────────────────────────────────
|
|
70
|
+
|
|
71
|
+
interface PersistedE2E {
|
|
72
|
+
pluginPrivB64: string;
|
|
73
|
+
pluginPubB64: string;
|
|
74
|
+
// Map of app device public keys to their derived SharedSecret
|
|
75
|
+
sharedSecrets: Record<string, string>;
|
|
76
|
+
// The active target device key
|
|
77
|
+
activeDevicePubKey: string | null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function getCryptoStorePath() {
|
|
81
|
+
const workDir = pluginRuntime?.config?.workspaceDir || process.cwd();
|
|
82
|
+
return path.join(workDir, "openclaw_mobile_keys.json");
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
let _persistedStore: Record<string, PersistedE2E> | null = null;
|
|
86
|
+
|
|
87
|
+
function loadE2EStore(): Record<string, PersistedE2E> {
|
|
88
|
+
if (_persistedStore) return _persistedStore;
|
|
89
|
+
try {
|
|
90
|
+
const p = getCryptoStorePath();
|
|
91
|
+
if (fs.existsSync(p)) {
|
|
92
|
+
_persistedStore = JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
93
|
+
} else {
|
|
94
|
+
_persistedStore = {};
|
|
95
|
+
}
|
|
96
|
+
} catch {
|
|
97
|
+
_persistedStore = {};
|
|
98
|
+
}
|
|
99
|
+
return _persistedStore!;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function saveE2EStore() {
|
|
103
|
+
try {
|
|
104
|
+
fs.writeFileSync(getCryptoStorePath(), JSON.stringify(_persistedStore, null, 2), "utf-8");
|
|
105
|
+
} catch (e) {
|
|
106
|
+
console.error("[openclaw-app] Failed to save mobile keys", e);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
async function getOrInitAccountE2E(accountId: string): Promise<PersistedE2E> {
|
|
111
|
+
const store = loadE2EStore();
|
|
112
|
+
if (!store[accountId]) {
|
|
113
|
+
const algo = await getX25519Algo();
|
|
114
|
+
const kp = await crypto.subtle.generateKey(algo.gen, true, ["deriveKey", "deriveBits"]);
|
|
115
|
+
const privRaw = await crypto.subtle.exportKey("pkcs8", kp.privateKey);
|
|
116
|
+
const pubRaw = await crypto.subtle.exportKey("raw", kp.publicKey);
|
|
117
|
+
store[accountId] = {
|
|
118
|
+
pluginPrivB64: bufToBase64Url(privRaw),
|
|
119
|
+
pluginPubB64: bufToBase64Url(pubRaw),
|
|
120
|
+
sharedSecrets: {},
|
|
121
|
+
activeDevicePubKey: null
|
|
122
|
+
};
|
|
123
|
+
saveE2EStore();
|
|
124
|
+
}
|
|
125
|
+
return store[accountId];
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
async function loadE2EStateFromPersisted(accountId: string, devicePubKeyB64: string): Promise<E2EState | null> {
|
|
129
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
130
|
+
const sharedSecretB64 = accountE2E.sharedSecrets[devicePubKeyB64];
|
|
131
|
+
if (!sharedSecretB64) return null;
|
|
132
|
+
|
|
133
|
+
const state = makeE2EState();
|
|
134
|
+
const rawShared = base64UrlToBuf(sharedSecretB64);
|
|
135
|
+
state.sharedKey = await crypto.subtle.importKey(
|
|
136
|
+
"raw",
|
|
137
|
+
rawShared,
|
|
138
|
+
{ name: "AES-GCM" },
|
|
139
|
+
false, // Not extractable anymore since it's already in memory/disk
|
|
140
|
+
["encrypt", "decrypt"]
|
|
141
|
+
);
|
|
142
|
+
state.ready = true;
|
|
143
|
+
return state;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
async function e2eHandleHandshake(accountId: string, peerPubKeyB64: string): Promise<E2EState> {
|
|
147
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
69
148
|
const algo = await getX25519Algo();
|
|
70
149
|
|
|
150
|
+
const privBytes = base64UrlToBuf(accountE2E.pluginPrivB64);
|
|
151
|
+
const privateKey = await crypto.subtle.importKey(
|
|
152
|
+
"pkcs8",
|
|
153
|
+
privBytes,
|
|
154
|
+
algo.imp,
|
|
155
|
+
false,
|
|
156
|
+
["deriveKey", "deriveBits"]
|
|
157
|
+
);
|
|
158
|
+
|
|
71
159
|
const peerPubKeyBytes = base64UrlToBuf(peerPubKeyB64);
|
|
72
160
|
const peerPublicKey = await crypto.subtle.importKey(
|
|
73
161
|
"raw",
|
|
74
|
-
peerPubKeyBytes
|
|
162
|
+
peerPubKeyBytes,
|
|
75
163
|
algo.imp,
|
|
76
164
|
false,
|
|
77
165
|
[]
|
|
78
166
|
);
|
|
79
167
|
|
|
80
|
-
|
|
168
|
+
// Dart cryptography's sharedSecretKey returns the raw X-coordinate (32 bytes).
|
|
169
|
+
// Using deriveKey into AES-GCM directly may truncate or alter the raw bits depending on the engine.
|
|
170
|
+
const ecdhRawBytes = await crypto.subtle.deriveBits(
|
|
81
171
|
{ name: algo.derive, public: peerPublicKey },
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
true,
|
|
85
|
-
["encrypt", "decrypt"]
|
|
172
|
+
privateKey,
|
|
173
|
+
256
|
|
86
174
|
);
|
|
87
|
-
const ecdhRawBytes = await crypto.subtle.exportKey("raw", ecdhRawKey);
|
|
88
175
|
|
|
89
176
|
// HKDF-SHA256: salt=empty, info="openclaw-e2e-v1" → AES-256-GCM key
|
|
90
177
|
const hkdfKey = await crypto.subtle.importKey(
|
|
91
|
-
"raw", ecdhRawBytes
|
|
178
|
+
"raw", ecdhRawBytes, { name: "HKDF" }, false, ["deriveKey"]
|
|
92
179
|
);
|
|
93
|
-
|
|
180
|
+
const sharedKey = await crypto.subtle.deriveKey(
|
|
94
181
|
{
|
|
95
182
|
name: "HKDF",
|
|
96
183
|
hash: "SHA-256",
|
|
@@ -99,11 +186,19 @@ async function e2eHandleHandshake(state: E2EState, peerPubKeyB64: string): Promi
|
|
|
99
186
|
},
|
|
100
187
|
hkdfKey,
|
|
101
188
|
{ name: "AES-GCM", length: 256 },
|
|
102
|
-
|
|
189
|
+
true, // EXPORTABLE so we can save it!
|
|
103
190
|
["encrypt", "decrypt"]
|
|
104
191
|
);
|
|
105
192
|
|
|
193
|
+
const rawShared = await crypto.subtle.exportKey("raw", sharedKey);
|
|
194
|
+
accountE2E.sharedSecrets[peerPubKeyB64] = bufToBase64Url(rawShared);
|
|
195
|
+
accountE2E.activeDevicePubKey = peerPubKeyB64;
|
|
196
|
+
saveE2EStore();
|
|
197
|
+
|
|
198
|
+
const state = makeE2EState();
|
|
199
|
+
state.sharedKey = sharedKey;
|
|
106
200
|
state.ready = true;
|
|
201
|
+
return state;
|
|
107
202
|
}
|
|
108
203
|
|
|
109
204
|
async function e2eEncrypt(state: E2EState, plaintext: string): Promise<string> {
|
|
@@ -126,27 +221,24 @@ async function e2eDecrypt(state: E2EState, nonceB64: string, ctB64: string): Pro
|
|
|
126
221
|
const nonce = base64UrlToBuf(nonceB64);
|
|
127
222
|
const ct = base64UrlToBuf(ctB64);
|
|
128
223
|
const plain = await crypto.subtle.decrypt(
|
|
129
|
-
{ name: "AES-GCM", iv: nonce
|
|
224
|
+
{ name: "AES-GCM", iv: nonce },
|
|
130
225
|
state.sharedKey,
|
|
131
|
-
ct
|
|
226
|
+
ct
|
|
132
227
|
);
|
|
133
228
|
return new TextDecoder().decode(plain);
|
|
134
229
|
}
|
|
135
230
|
|
|
136
231
|
function bufToBase64Url(buf: ArrayBuffer | Uint8Array): string {
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
for (const b of bytes) binary += String.fromCharCode(b);
|
|
140
|
-
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
232
|
+
const buffer = Buffer.from(buf);
|
|
233
|
+
return buffer.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "");
|
|
141
234
|
}
|
|
142
235
|
|
|
143
236
|
function base64UrlToBuf(b64: string): Uint8Array {
|
|
144
237
|
const padded = b64.replace(/-/g, "+").replace(/_/g, "/");
|
|
145
|
-
const
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
return bytes;
|
|
238
|
+
const buffer = Buffer.from(padded, "base64");
|
|
239
|
+
const cleanBytes = new Uint8Array(buffer.length);
|
|
240
|
+
cleanBytes.set(buffer);
|
|
241
|
+
return cleanBytes;
|
|
150
242
|
}
|
|
151
243
|
|
|
152
244
|
const HANDSHAKE_AUTH_VERSION = "v1";
|
|
@@ -235,18 +327,8 @@ interface RelayState {
|
|
|
235
327
|
pingTimer: ReturnType<typeof setInterval> | null;
|
|
236
328
|
statusSink: ((patch: Record<string, unknown>) => void) | null;
|
|
237
329
|
gatewayCtx: any;
|
|
238
|
-
/**
|
|
239
|
-
* Per-session E2E state keyed by the app's session UUID.
|
|
240
|
-
* Each app connection gets its own X25519 keypair + AES-256-GCM shared key
|
|
241
|
-
* so multiple users can be active simultaneously without key collisions.
|
|
242
|
-
*/
|
|
243
|
-
e2eSessions: Map<string, E2EState>;
|
|
244
|
-
/** Previous E2E states kept for decrypting offline-buffered messages
|
|
245
|
-
* that were encrypted with the old key before the app reconnected. */
|
|
246
|
-
prevE2eSessions: Map<string, E2EState>;
|
|
247
|
-
/** Buffered pending_flush payloads waiting for the new E2E handshake to complete. */
|
|
248
|
-
pendingFlushQueue: Map<string, string[]>;
|
|
249
330
|
relayToken: string;
|
|
331
|
+
lastActiveSessionKey?: string;
|
|
250
332
|
}
|
|
251
333
|
|
|
252
334
|
const relayStates = new Map<string, RelayState>();
|
|
@@ -255,6 +337,9 @@ const RECONNECT_DELAY = 5000;
|
|
|
255
337
|
const PING_INTERVAL = 30_000; // 30s keepalive — prevents DO hibernation
|
|
256
338
|
const DEFAULT_ACCOUNT_ID = "default";
|
|
257
339
|
const CHANNEL_ID = "openclaw-app";
|
|
340
|
+
const PLUGIN_VERSION = "1.1.9";
|
|
341
|
+
/** Fixed chatSessionKey that routes cron messages to the App inbox UI */
|
|
342
|
+
const INBOX_CHAT_SESSION_KEY = "inbox";
|
|
258
343
|
|
|
259
344
|
function getRelayState(accountId: string): RelayState {
|
|
260
345
|
let state = relayStates.get(accountId);
|
|
@@ -265,9 +350,6 @@ function getRelayState(accountId: string): RelayState {
|
|
|
265
350
|
pingTimer: null,
|
|
266
351
|
statusSink: null,
|
|
267
352
|
gatewayCtx: null,
|
|
268
|
-
e2eSessions: new Map(),
|
|
269
|
-
prevE2eSessions: new Map(),
|
|
270
|
-
pendingFlushQueue: new Map(),
|
|
271
353
|
relayToken: "",
|
|
272
354
|
};
|
|
273
355
|
relayStates.set(accountId, state);
|
|
@@ -415,52 +497,124 @@ const channel = {
|
|
|
415
497
|
relayUrl: account.relayUrl,
|
|
416
498
|
roomId: account.roomId,
|
|
417
499
|
}),
|
|
500
|
+
// OpenClaw's resolveOutboundTarget() falls back to this when no explicit
|
|
501
|
+
// `to` is provided in delivery config. Our channel always targets the
|
|
502
|
+
// single connected mobile user.
|
|
503
|
+
resolveDefaultTo: () => "openclaw-app-user",
|
|
418
504
|
},
|
|
419
505
|
outbound: {
|
|
420
506
|
deliveryMode: "direct" as const,
|
|
421
507
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
508
|
+
// OpenClaw's ChannelOutboundAdapter.resolveTarget
|
|
509
|
+
// Called by resolveOutboundTarget() in src/infra/outbound/targets.ts
|
|
510
|
+
// to validate/normalize the delivery target address.
|
|
511
|
+
resolveTarget: ({ to }: { to?: string; allowFrom?: string[]; accountId?: string | null; mode?: string }) => {
|
|
512
|
+
const target = to?.trim() || "openclaw-app-user";
|
|
513
|
+
return { ok: true as const, to: target };
|
|
514
|
+
},
|
|
515
|
+
|
|
516
|
+
// OpenClaw's ChannelOutboundAdapter.sendText
|
|
517
|
+
// Called by createPluginHandler() in src/infra/outbound/deliver.ts
|
|
518
|
+
// ctx: ChannelOutboundContext = { cfg, to, text, accountId, ... }
|
|
519
|
+
sendText: async (ctx: any) => {
|
|
520
|
+
const text = ctx.text ?? "";
|
|
521
|
+
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
522
|
+
const state = getRelayState(accountId);
|
|
425
523
|
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
426
|
-
|
|
524
|
+
throw new Error("relay not connected");
|
|
427
525
|
}
|
|
428
526
|
|
|
429
527
|
const runtime = pluginRuntime;
|
|
430
|
-
let outText = text
|
|
528
|
+
let outText = text;
|
|
431
529
|
if (runtime) {
|
|
432
530
|
const cfg = runtime.config.loadConfig();
|
|
433
531
|
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
434
532
|
cfg,
|
|
435
533
|
channel: CHANNEL_ID,
|
|
436
|
-
accountId
|
|
534
|
+
accountId,
|
|
437
535
|
});
|
|
438
536
|
outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
|
|
439
537
|
}
|
|
440
538
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
539
|
+
// Prefer the App's current active session for delivery
|
|
540
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
541
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
542
|
+
throw new Error("no active E2E session available");
|
|
444
543
|
}
|
|
445
|
-
const sessionE2E =
|
|
544
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
446
545
|
if (!sessionE2E?.ready) {
|
|
447
|
-
|
|
546
|
+
throw new Error("persistent E2E session not ready");
|
|
448
547
|
}
|
|
548
|
+
|
|
549
|
+
const replySessionKey = state.lastActiveSessionKey || "inbox-worker";
|
|
550
|
+
const chatKey = INBOX_CHAT_SESSION_KEY;
|
|
551
|
+
const messageId = `mobile-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
|
552
|
+
|
|
449
553
|
const plainMsg = JSON.stringify({
|
|
450
554
|
type: "message",
|
|
451
555
|
role: "assistant",
|
|
452
556
|
content: outText,
|
|
453
557
|
sessionKey: replySessionKey,
|
|
558
|
+
chatSessionKey: chatKey,
|
|
559
|
+
messageId,
|
|
454
560
|
});
|
|
455
561
|
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
456
562
|
encrypted.sessionKey = replySessionKey;
|
|
563
|
+
encrypted.chatSessionKey = chatKey;
|
|
564
|
+
encrypted.messageId = messageId;
|
|
457
565
|
const outMsg = JSON.stringify(encrypted);
|
|
566
|
+
|
|
567
|
+
const logger = pluginRuntime?.logger ?? console;
|
|
568
|
+
logger.info?.(`[${CHANNEL_ID}] sendText: targetSessionKey=${replySessionKey} chatKey=${chatKey} wsState=${state.ws?.readyState} msgLen=${outMsg.length}`);
|
|
569
|
+
|
|
458
570
|
state.ws.send(outMsg);
|
|
459
571
|
|
|
460
572
|
return {
|
|
461
573
|
channel: CHANNEL_ID,
|
|
462
|
-
|
|
463
|
-
|
|
574
|
+
messageId,
|
|
575
|
+
};
|
|
576
|
+
},
|
|
577
|
+
|
|
578
|
+
// OpenClaw's ChannelOutboundAdapter.sendMedia (required alongside sendText)
|
|
579
|
+
sendMedia: async (ctx: any) => {
|
|
580
|
+
// Media not supported over E2E relay — send caption text only
|
|
581
|
+
const text = ctx.text ?? "";
|
|
582
|
+
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
583
|
+
const state = getRelayState(accountId);
|
|
584
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
585
|
+
throw new Error("relay not connected");
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
589
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
590
|
+
throw new Error("no active E2E session available");
|
|
591
|
+
}
|
|
592
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
593
|
+
if (!sessionE2E?.ready) {
|
|
594
|
+
throw new Error("persistent E2E session not ready");
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
const replySessionKey = state.lastActiveSessionKey || "inbox-worker";
|
|
598
|
+
const chatKey = INBOX_CHAT_SESSION_KEY;
|
|
599
|
+
const messageId = `mobile-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
|
600
|
+
|
|
601
|
+
const plainMsg = JSON.stringify({
|
|
602
|
+
type: "message",
|
|
603
|
+
role: "assistant",
|
|
604
|
+
content: text || "[media]",
|
|
605
|
+
sessionKey: replySessionKey,
|
|
606
|
+
chatSessionKey: chatKey,
|
|
607
|
+
messageId,
|
|
608
|
+
});
|
|
609
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
610
|
+
encrypted.sessionKey = replySessionKey;
|
|
611
|
+
encrypted.chatSessionKey = chatKey;
|
|
612
|
+
encrypted.messageId = messageId;
|
|
613
|
+
state.ws.send(JSON.stringify(encrypted));
|
|
614
|
+
|
|
615
|
+
return {
|
|
616
|
+
channel: CHANNEL_ID,
|
|
617
|
+
messageId: `mobile-media-${Date.now()}`,
|
|
464
618
|
};
|
|
465
619
|
},
|
|
466
620
|
},
|
|
@@ -559,13 +713,9 @@ function cleanupRelay(state: RelayState) {
|
|
|
559
713
|
state.reconnectTimer = null;
|
|
560
714
|
}
|
|
561
715
|
if (state.ws) {
|
|
562
|
-
try { state.ws.close(); } catch {}
|
|
716
|
+
try { state.ws.close(); } catch { }
|
|
563
717
|
state.ws = null;
|
|
564
718
|
}
|
|
565
|
-
state.e2eSessions.clear();
|
|
566
|
-
// prevE2eSessions is intentionally kept across reconnects so that
|
|
567
|
-
// offline-buffered messages can still be re-encrypted after a plugin
|
|
568
|
-
// reconnect. pendingFlushQueue is also kept for the same reason.
|
|
569
719
|
}
|
|
570
720
|
|
|
571
721
|
function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
@@ -624,9 +774,10 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
|
624
774
|
});
|
|
625
775
|
});
|
|
626
776
|
|
|
627
|
-
state.ws.addEventListener("close", () => {
|
|
777
|
+
state.ws.addEventListener("close", (event: any) => {
|
|
778
|
+
if (state.ws !== null && state.ws !== event.target) return; // Ignore stale connections
|
|
628
779
|
ctx.log?.info?.(
|
|
629
|
-
`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting
|
|
780
|
+
`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting... (code: ${event.code}, reason: ${event.reason})`
|
|
630
781
|
);
|
|
631
782
|
state.ws = null;
|
|
632
783
|
if (state.pingTimer) {
|
|
@@ -640,9 +791,10 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
|
640
791
|
scheduleReconnect(ctx, account);
|
|
641
792
|
});
|
|
642
793
|
|
|
643
|
-
state.ws.addEventListener("error", () => {
|
|
644
|
-
|
|
645
|
-
|
|
794
|
+
state.ws.addEventListener("error", (event: any) => {
|
|
795
|
+
const errMsg = event?.message || String(event);
|
|
796
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Relay WebSocket error: ${errMsg}`);
|
|
797
|
+
state.statusSink?.({ lastError: `WebSocket error: ${errMsg}` });
|
|
646
798
|
});
|
|
647
799
|
}
|
|
648
800
|
|
|
@@ -655,41 +807,6 @@ function scheduleReconnect(ctx: any, account: ResolvedAccount) {
|
|
|
655
807
|
}, RECONNECT_DELAY);
|
|
656
808
|
}
|
|
657
809
|
|
|
658
|
-
async function processPendingFlush(
|
|
659
|
-
ctx: any, accountId: string, state: RelayState, sessionKey: string, messages: string[]
|
|
660
|
-
): Promise<void> {
|
|
661
|
-
const oldE2E = state.prevE2eSessions.get(sessionKey);
|
|
662
|
-
const newE2E = state.e2eSessions.get(sessionKey);
|
|
663
|
-
if (!oldE2E?.ready) {
|
|
664
|
-
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] pending_flush: no old key for session ${sessionKey}, dropping ${messages.length} message(s)`);
|
|
665
|
-
return;
|
|
666
|
-
}
|
|
667
|
-
if (!newE2E?.ready) {
|
|
668
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] pending_flush: new E2E not ready, queueing for session ${sessionKey}`);
|
|
669
|
-
state.pendingFlushQueue.set(sessionKey, messages);
|
|
670
|
-
return;
|
|
671
|
-
}
|
|
672
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] pending_flush: re-encrypting ${messages.length} message(s) for session ${sessionKey}`);
|
|
673
|
-
for (const raw of messages) {
|
|
674
|
-
try {
|
|
675
|
-
const parsed = JSON.parse(raw);
|
|
676
|
-
if (parsed.type !== "encrypted" || !parsed.nonce || !parsed.ct) {
|
|
677
|
-
if (!parsed.sessionKey) parsed.sessionKey = sessionKey;
|
|
678
|
-
state.ws?.send(JSON.stringify(parsed));
|
|
679
|
-
continue;
|
|
680
|
-
}
|
|
681
|
-
const plaintext = await e2eDecrypt(oldE2E, parsed.nonce, parsed.ct);
|
|
682
|
-
const reEncrypted = JSON.parse(await e2eEncrypt(newE2E, plaintext));
|
|
683
|
-
reEncrypted.sessionKey = sessionKey;
|
|
684
|
-
state.ws?.send(JSON.stringify(reEncrypted));
|
|
685
|
-
} catch (e) {
|
|
686
|
-
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] pending_flush: failed to re-encrypt: ${e}`);
|
|
687
|
-
}
|
|
688
|
-
}
|
|
689
|
-
state.prevE2eSessions.delete(sessionKey);
|
|
690
|
-
state.pendingFlushQueue.delete(sessionKey);
|
|
691
|
-
}
|
|
692
|
-
|
|
693
810
|
async function handleRelayMessage(ctx: any, accountId: string, state: RelayState, raw: string): Promise<void> {
|
|
694
811
|
// Skip ping/pong
|
|
695
812
|
if (raw === "ping" || raw === "pong") return;
|
|
@@ -698,77 +815,22 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
698
815
|
|
|
699
816
|
const msg = JSON.parse(raw);
|
|
700
817
|
|
|
701
|
-
// App
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
if (msg.type === "peer_joined") {
|
|
818
|
+
// App's handshake request (V2: App initiates with its persistent PubKey)
|
|
819
|
+
if (msg.type === "handshake") {
|
|
820
|
+
const peerPubKey = msg.pubkey as string | undefined;
|
|
705
821
|
const sessionKey = msg.sessionKey as string | undefined;
|
|
706
|
-
|
|
707
|
-
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] peer_joined missing sessionKey, ignoring`);
|
|
708
|
-
return;
|
|
709
|
-
}
|
|
710
|
-
// Preserve old E2E state for decrypting offline-buffered messages,
|
|
711
|
-
// then create a fresh state for the new handshake.
|
|
712
|
-
const oldE2E = state.e2eSessions.get(sessionKey);
|
|
713
|
-
if (oldE2E?.ready) {
|
|
714
|
-
state.prevE2eSessions.set(sessionKey, oldE2E);
|
|
715
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} reconnected, old key preserved for pending_flush`);
|
|
716
|
-
}
|
|
717
|
-
state.e2eSessions.delete(sessionKey);
|
|
718
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] App session joined (${sessionKey}), sending handshake`);
|
|
719
|
-
const sessionE2E = makeE2EState();
|
|
720
|
-
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
721
|
-
const pubkey = await e2eInit(sessionE2E);
|
|
722
|
-
const handshakePayload: Record<string, unknown> = {
|
|
723
|
-
type: "handshake",
|
|
724
|
-
sessionKey,
|
|
725
|
-
pubkey,
|
|
726
|
-
};
|
|
727
|
-
if (state.relayToken) {
|
|
728
|
-
const ts = Date.now();
|
|
729
|
-
const mac = await buildHandshakeMac(
|
|
730
|
-
state.relayToken,
|
|
731
|
-
"plugin",
|
|
732
|
-
sessionKey,
|
|
733
|
-
pubkey,
|
|
734
|
-
ts,
|
|
735
|
-
HANDSHAKE_AUTH_VERSION
|
|
736
|
-
);
|
|
737
|
-
handshakePayload.v = HANDSHAKE_AUTH_VERSION;
|
|
738
|
-
handshakePayload.ts = ts;
|
|
739
|
-
handshakePayload.mac = mac;
|
|
740
|
-
}
|
|
741
|
-
const handshakeWithSession = JSON.stringify(handshakePayload);
|
|
742
|
-
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
743
|
-
state.ws.send(handshakeWithSession);
|
|
744
|
-
}
|
|
745
|
-
return;
|
|
746
|
-
}
|
|
822
|
+
const msgTs = msg.ts as number | undefined;
|
|
747
823
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
const sessionKey = msg.sessionKey as string | undefined;
|
|
751
|
-
const messages = msg.messages as string[] | undefined;
|
|
752
|
-
if (!sessionKey || !messages || messages.length === 0) {
|
|
753
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] pending_flush: nothing to flush`);
|
|
824
|
+
if (!peerPubKey) {
|
|
825
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] handshake missing pubkey, ignoring`);
|
|
754
826
|
return;
|
|
755
827
|
}
|
|
756
|
-
await processPendingFlush(ctx, accountId, state, sessionKey, messages);
|
|
757
|
-
return;
|
|
758
|
-
}
|
|
759
828
|
|
|
760
|
-
// App의 handshake 응답 수신 — ECDH 완성
|
|
761
|
-
if (msg.type === "handshake") {
|
|
762
|
-
const sessionKey = msg.sessionKey as string | undefined;
|
|
763
|
-
const peerPubKey = msg.pubkey as string | undefined;
|
|
764
|
-
const peerMac = msg.mac as string | undefined;
|
|
765
|
-
const version = (msg.v as string | undefined) ?? HANDSHAKE_AUTH_VERSION;
|
|
766
|
-
const peerTs = parseHandshakeTs(msg.ts);
|
|
767
|
-
if (!sessionKey || !peerPubKey) {
|
|
768
|
-
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing sessionKey or pubkey`);
|
|
769
|
-
return;
|
|
770
|
-
}
|
|
771
829
|
if (state.relayToken) {
|
|
830
|
+
const peerMac = msg.mac as string | undefined;
|
|
831
|
+
const peerTs = msg.ts as number | undefined;
|
|
832
|
+
const version = msg.v as string | undefined;
|
|
833
|
+
|
|
772
834
|
if (!peerMac || peerTs == null) {
|
|
773
835
|
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing auth fields`);
|
|
774
836
|
return;
|
|
@@ -781,104 +843,88 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
781
843
|
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake timestamp out of window, dropping`);
|
|
782
844
|
return;
|
|
783
845
|
}
|
|
784
|
-
const verified = await verifyHandshakeMac(
|
|
785
|
-
state.relayToken,
|
|
786
|
-
"app",
|
|
787
|
-
sessionKey,
|
|
788
|
-
peerPubKey,
|
|
789
|
-
peerTs,
|
|
790
|
-
peerMac,
|
|
791
|
-
version
|
|
792
|
-
);
|
|
793
|
-
if (!verified) {
|
|
794
|
-
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake MAC verification failed, dropping`);
|
|
795
|
-
return;
|
|
796
|
-
}
|
|
797
846
|
}
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
state.
|
|
804
|
-
const pubkey = await e2eInit(sessionE2E);
|
|
805
|
-
const handshakePayload: Record<string, unknown> = {
|
|
806
|
-
type: "handshake",
|
|
807
|
-
sessionKey,
|
|
808
|
-
pubkey,
|
|
809
|
-
};
|
|
810
|
-
if (state.relayToken) {
|
|
811
|
-
const ts = Date.now();
|
|
812
|
-
const mac = await buildHandshakeMac(
|
|
813
|
-
state.relayToken,
|
|
814
|
-
"plugin",
|
|
815
|
-
sessionKey,
|
|
816
|
-
pubkey,
|
|
817
|
-
ts,
|
|
818
|
-
HANDSHAKE_AUTH_VERSION
|
|
819
|
-
);
|
|
820
|
-
handshakePayload.v = HANDSHAKE_AUTH_VERSION;
|
|
821
|
-
handshakePayload.ts = ts;
|
|
822
|
-
handshakePayload.mac = mac;
|
|
823
|
-
}
|
|
824
|
-
const handshakeWithSession = JSON.stringify(handshakePayload);
|
|
825
|
-
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
826
|
-
state.ws.send(handshakeWithSession);
|
|
827
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} — sent handshake (reactive, no prior peer_joined)`);
|
|
828
|
-
}
|
|
829
|
-
}
|
|
830
|
-
if (sessionE2E.ready) {
|
|
831
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} already ready, ignoring duplicate handshake`);
|
|
832
|
-
return;
|
|
847
|
+
|
|
848
|
+
// V2: Derive persistent shared secret and store it
|
|
849
|
+
await e2eHandleHandshake(accountId, peerPubKey);
|
|
850
|
+
|
|
851
|
+
if (sessionKey) {
|
|
852
|
+
state.lastActiveSessionKey = sessionKey;
|
|
833
853
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
854
|
+
|
|
855
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake handled for app pubkey ${peerPubKey.slice(0, 8)}...`);
|
|
856
|
+
|
|
857
|
+
// Reply with our persistent pubkey (Plugin PubKey) so App knows it
|
|
858
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
859
|
+
const replyPayload: Record<string, unknown> = {
|
|
860
|
+
type: "handshake",
|
|
861
|
+
sessionKey: sessionKey || "",
|
|
862
|
+
pubkey: accountE2E.pluginPubB64,
|
|
863
|
+
};
|
|
864
|
+
|
|
865
|
+
if (state.relayToken) {
|
|
866
|
+
const ts = Date.now();
|
|
867
|
+
const mac = await buildHandshakeMac(
|
|
868
|
+
state.relayToken,
|
|
869
|
+
"plugin",
|
|
870
|
+
sessionKey || "",
|
|
871
|
+
accountE2E.pluginPubB64,
|
|
872
|
+
ts,
|
|
873
|
+
HANDSHAKE_AUTH_VERSION
|
|
874
|
+
);
|
|
875
|
+
replyPayload.v = HANDSHAKE_AUTH_VERSION;
|
|
876
|
+
replyPayload.ts = ts;
|
|
877
|
+
replyPayload.mac = mac;
|
|
837
878
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
// Process any queued pending_flush that arrived before this handshake completed
|
|
842
|
-
const queued = state.pendingFlushQueue.get(sessionKey);
|
|
843
|
-
if (queued && queued.length > 0) {
|
|
844
|
-
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Processing queued pending_flush for session ${sessionKey}`);
|
|
845
|
-
await processPendingFlush(ctx, accountId, state, sessionKey, queued);
|
|
879
|
+
|
|
880
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
881
|
+
state.ws.send(JSON.stringify(replyPayload));
|
|
846
882
|
}
|
|
847
883
|
return;
|
|
848
884
|
}
|
|
849
|
-
|
|
850
|
-
// E2E encrypted message — decrypt using the per-session key
|
|
885
|
+
// Process E2E encrypted messages from the App
|
|
851
886
|
if (msg.type === "encrypted") {
|
|
852
|
-
const
|
|
853
|
-
|
|
854
|
-
|
|
887
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
888
|
+
|
|
889
|
+
// V2.1: Use the explicit explicit device pubkey embedded in the envelope, otherwise
|
|
890
|
+
// fallback to the connection activeDevicePubKey handling (V2/legacy)
|
|
891
|
+
const devicePubKey = msg.pubkey || accountE2E.activeDevicePubKey;
|
|
892
|
+
|
|
893
|
+
if (!devicePubKey) {
|
|
894
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] No active device key, dropping encrypted message`);
|
|
855
895
|
return;
|
|
856
896
|
}
|
|
857
|
-
|
|
897
|
+
|
|
898
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, devicePubKey);
|
|
858
899
|
if (!sessionE2E?.ready) {
|
|
859
|
-
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E]
|
|
900
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Failed to load SharedSecret for active device, dropping`);
|
|
860
901
|
return;
|
|
861
902
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
903
|
+
|
|
904
|
+
try {
|
|
905
|
+
const plaintext = await e2eDecrypt(sessionE2E, msg.nonce, msg.ct);
|
|
906
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Decrypted message: ${plaintext.slice(0, 200)}`);
|
|
907
|
+
const innerMsg = JSON.parse(plaintext);
|
|
908
|
+
await handleInbound(ctx, accountId, innerMsg);
|
|
909
|
+
} catch (e) {
|
|
910
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Decryption failed: ${e}`);
|
|
911
|
+
}
|
|
868
912
|
return;
|
|
869
913
|
}
|
|
870
914
|
|
|
871
|
-
//
|
|
915
|
+
// Drop any plaintext message that sneaks through (only handshake/encrypted should be processed)
|
|
872
916
|
if (msg.type === "message" || msg.type === "delta" || msg.type === "final" || msg.type === "abort") {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
if (!sessionE2E?.ready) {
|
|
876
|
-
ctx.log?.warn?.(
|
|
877
|
-
`[${CHANNEL_ID}] [${accountId}] [E2E] Dropping plaintext ${msg.type} (session not encrypted yet)`
|
|
878
|
-
);
|
|
879
|
-
return;
|
|
880
|
-
}
|
|
917
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Dropping plaintext ${msg.type} message for security`);
|
|
918
|
+
return;
|
|
881
919
|
}
|
|
920
|
+
|
|
921
|
+
// Silently ignore relay system events that don't need action on the plugin side
|
|
922
|
+
if (msg.type === "peer_joined" || msg.type === "peer_left") {
|
|
923
|
+
ctx.log?.debug?.(`[${CHANNEL_ID}] [${accountId}] Relay system event ignored: ${msg.type}`);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Fallback for any other system message
|
|
882
928
|
await handleInbound(ctx, accountId, msg);
|
|
883
929
|
}
|
|
884
930
|
|
|
@@ -902,12 +948,22 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
902
948
|
const sendRpcReply = async (data: unknown, error?: string) => {
|
|
903
949
|
if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN) return;
|
|
904
950
|
if (!replySessionKey) return;
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
951
|
+
|
|
952
|
+
try {
|
|
953
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
954
|
+
if (!accountE2E.activeDevicePubKey) return;
|
|
955
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
956
|
+
if (!sessionE2E?.ready) return;
|
|
957
|
+
|
|
958
|
+
const msgId = crypto.randomUUID();
|
|
959
|
+
const inner = JSON.stringify({ type: "rpc-response", id: reqId, data, error: error ?? null, sessionKey: replySessionKey, messageId: msgId });
|
|
960
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, inner));
|
|
961
|
+
encrypted.sessionKey = replySessionKey;
|
|
962
|
+
encrypted.messageId = msgId;
|
|
963
|
+
relayState.ws.send(JSON.stringify(encrypted));
|
|
964
|
+
} catch (e) {
|
|
965
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] sendRpcReply fail: ${e}`);
|
|
966
|
+
}
|
|
911
967
|
};
|
|
912
968
|
|
|
913
969
|
try {
|
|
@@ -972,7 +1028,7 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
972
1028
|
entriesSample: Object.entries(cfg?.skills?.entries ?? {}).slice(0, 5)
|
|
973
1029
|
.map(([k, v]: [string, any]) => ({ name: k, enabled: v?.enabled })),
|
|
974
1030
|
};
|
|
975
|
-
} catch (_) {}
|
|
1031
|
+
} catch (_) { }
|
|
976
1032
|
|
|
977
1033
|
await sendRpcReply({ runtimeShape, skillsProbe, cfgSkills });
|
|
978
1034
|
} else if (method === "agents.list") {
|
|
@@ -1039,11 +1095,11 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1039
1095
|
10000
|
|
1040
1096
|
);
|
|
1041
1097
|
}
|
|
1042
|
-
if (cliResult.exitCode === 0 && cliResult.stdout?.trim()) {
|
|
1098
|
+
if (cliResult && cliResult.exitCode === 0 && cliResult.stdout?.trim()) {
|
|
1043
1099
|
const parsed = JSON.parse(cliResult.stdout.trim());
|
|
1044
1100
|
const raw: any[] = Array.isArray(parsed) ? parsed
|
|
1045
1101
|
: Array.isArray(parsed?.skills) ? parsed.skills
|
|
1046
|
-
|
|
1102
|
+
: parsed?.data ?? [];
|
|
1047
1103
|
tools = raw
|
|
1048
1104
|
.filter((s: any) => s['user-invocable'] !== false && s['user-invocable'] !== 'false')
|
|
1049
1105
|
.map((s: any) => ({
|
|
@@ -1054,7 +1110,7 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1054
1110
|
}))
|
|
1055
1111
|
.filter((t: any) => t.name);
|
|
1056
1112
|
}
|
|
1057
|
-
} catch (_) {}
|
|
1113
|
+
} catch (_) { }
|
|
1058
1114
|
|
|
1059
1115
|
// Fallback: scan ~/.openclaw for all skills/ subdirs, deduplicated
|
|
1060
1116
|
if (tools.length === 0) {
|
|
@@ -1073,7 +1129,7 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1073
1129
|
const addSkillsDir = (dir: string) => {
|
|
1074
1130
|
try {
|
|
1075
1131
|
if (fs.existsSync(dir)) skillDirs.push(dir);
|
|
1076
|
-
} catch (_) {}
|
|
1132
|
+
} catch (_) { }
|
|
1077
1133
|
};
|
|
1078
1134
|
|
|
1079
1135
|
// ~/.openclaw/skills
|
|
@@ -1085,7 +1141,7 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1085
1141
|
const sub = path.join(ocRoot, entry, 'skills');
|
|
1086
1142
|
addSkillsDir(sub);
|
|
1087
1143
|
}
|
|
1088
|
-
} catch (_) {}
|
|
1144
|
+
} catch (_) { }
|
|
1089
1145
|
|
|
1090
1146
|
// Extra dirs from config
|
|
1091
1147
|
for (const d of (cfg?.skills?.load?.extraDirs ?? [])) addSkillsDir(d);
|
|
@@ -1107,10 +1163,20 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1107
1163
|
tools.push({ name, description: fm.description ?? '', category: '', userInvocable: true });
|
|
1108
1164
|
}
|
|
1109
1165
|
}
|
|
1110
|
-
} catch (_) {}
|
|
1166
|
+
} catch (_) { }
|
|
1111
1167
|
}
|
|
1112
1168
|
|
|
1113
1169
|
await sendRpcReply({ tools });
|
|
1170
|
+
} else if (method === "plugin.version") {
|
|
1171
|
+
await sendRpcReply({
|
|
1172
|
+
pluginVersion: PLUGIN_VERSION,
|
|
1173
|
+
channelId: CHANNEL_ID,
|
|
1174
|
+
});
|
|
1175
|
+
} else if (method === "inbox.test") {
|
|
1176
|
+
// Debug/test: send a test message to the App inbox
|
|
1177
|
+
const testText = (params.text as string) || "🔔 Inbox test — if you see this, the pipeline works!";
|
|
1178
|
+
const sent = await _sendToInbox(accountId, testText, { logger: ctx.log });
|
|
1179
|
+
await sendRpcReply({ sent, message: sent ? "delivered" : "no active E2E session" });
|
|
1114
1180
|
} else if (method === "tools.invoke") {
|
|
1115
1181
|
// Invoke a skill by injecting a /skill <name> command into the chat session
|
|
1116
1182
|
const toolName = params.name as string | undefined;
|
|
@@ -1133,6 +1199,25 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1133
1199
|
await sendRpcReply(null, `tools.invoke '${toolName}' error: ${e}`);
|
|
1134
1200
|
}
|
|
1135
1201
|
}
|
|
1202
|
+
} else if (method === "chat.abort") {
|
|
1203
|
+
const abortSessionKey = (params.sessionKey as string) || replySessionKey;
|
|
1204
|
+
const runId = params.runId as string | undefined;
|
|
1205
|
+
if (!abortSessionKey) {
|
|
1206
|
+
await sendRpcReply(null, "chat.abort: missing required param 'sessionKey'");
|
|
1207
|
+
} else {
|
|
1208
|
+
try {
|
|
1209
|
+
// Signal the reply engine to abort any active generation for this session/runId
|
|
1210
|
+
if (typeof runtime.channel.reply.abortDispatch === "function") {
|
|
1211
|
+
await runtime.channel.reply.abortDispatch(abortSessionKey, runId);
|
|
1212
|
+
await sendRpcReply({ aborted: true, sessionKey: abortSessionKey, runId });
|
|
1213
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Aborted chat dispatch for session ${abortSessionKey} (runId: ${runId})`);
|
|
1214
|
+
} else {
|
|
1215
|
+
await sendRpcReply(null, "chat.abort is not supported by this OpenClaw version");
|
|
1216
|
+
}
|
|
1217
|
+
} catch (e: any) {
|
|
1218
|
+
await sendRpcReply(null, `chat.abort error: ${e}`);
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1136
1221
|
} else {
|
|
1137
1222
|
await sendRpcReply(null, `Unknown RPC method: ${method}`);
|
|
1138
1223
|
}
|
|
@@ -1163,8 +1248,8 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
1163
1248
|
return;
|
|
1164
1249
|
}
|
|
1165
1250
|
|
|
1166
|
-
const senderId = msg.senderId ?? "
|
|
1167
|
-
const senderName = msg.senderName ?? "
|
|
1251
|
+
const senderId = msg.senderId ?? "openclaw-app-user";
|
|
1252
|
+
const senderName = msg.senderName ?? "openclaw-app-user";
|
|
1168
1253
|
const text = String(msg.content);
|
|
1169
1254
|
const chatSessionKey = msg.chatSessionKey ? String(msg.chatSessionKey) : null;
|
|
1170
1255
|
|
|
@@ -1246,38 +1331,90 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
1246
1331
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
1247
1332
|
runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
1248
1333
|
humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
1334
|
+
onTyping: async () => {
|
|
1335
|
+
const relayState = getRelayState(accountId);
|
|
1336
|
+
if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN) return;
|
|
1337
|
+
|
|
1338
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
1339
|
+
if (!accountE2E.activeDevicePubKey) return;
|
|
1340
|
+
|
|
1341
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
1342
|
+
if (!sessionE2E?.ready) return;
|
|
1343
|
+
|
|
1344
|
+
const replySessionKey = appSessionKey ?? sessionKey;
|
|
1345
|
+
const innerPayload: Record<string, unknown> = {
|
|
1346
|
+
type: "typing",
|
|
1347
|
+
sessionKey: replySessionKey,
|
|
1348
|
+
};
|
|
1349
|
+
if (chatSessionKey) innerPayload.chatSessionKey = chatSessionKey;
|
|
1350
|
+
|
|
1351
|
+
try {
|
|
1352
|
+
const plainMsg = JSON.stringify(innerPayload);
|
|
1353
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
1354
|
+
encrypted.sessionKey = replySessionKey;
|
|
1355
|
+
if (chatSessionKey) encrypted.chatSessionKey = chatSessionKey;
|
|
1356
|
+
relayState.ws.send(JSON.stringify(encrypted));
|
|
1357
|
+
} catch (e) {
|
|
1358
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Failed to send typing: ${e}`);
|
|
1359
|
+
}
|
|
1360
|
+
},
|
|
1249
1361
|
deliver: async (payload: any) => {
|
|
1250
1362
|
const relayState = getRelayState(accountId);
|
|
1251
1363
|
if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN) {
|
|
1252
1364
|
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
|
|
1253
1365
|
return;
|
|
1254
1366
|
}
|
|
1367
|
+
|
|
1368
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
1369
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
1370
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Reply blocked: no active device key for account`);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
1375
|
+
if (!sessionE2E?.ready) {
|
|
1376
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Reply blocked: persistent session not ready`);
|
|
1377
|
+
return;
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
const replySessionKey = appSessionKey ?? sessionKey;
|
|
1381
|
+
|
|
1382
|
+
if (payload.type === "typing") {
|
|
1383
|
+
const innerPayload: Record<string, unknown> = {
|
|
1384
|
+
type: "typing",
|
|
1385
|
+
sessionKey: replySessionKey,
|
|
1386
|
+
};
|
|
1387
|
+
if (chatSessionKey) innerPayload.chatSessionKey = chatSessionKey;
|
|
1388
|
+
const plainMsg = JSON.stringify(innerPayload);
|
|
1389
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
1390
|
+
encrypted.sessionKey = replySessionKey;
|
|
1391
|
+
if (chatSessionKey) encrypted.chatSessionKey = chatSessionKey;
|
|
1392
|
+
relayState.ws.send(JSON.stringify(encrypted));
|
|
1393
|
+
return;
|
|
1394
|
+
}
|
|
1395
|
+
|
|
1255
1396
|
const replyText = runtime.channel.text.convertMarkdownTables(
|
|
1256
1397
|
payload.text ?? "", tableMode,
|
|
1257
1398
|
);
|
|
1258
1399
|
const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
|
|
1259
1400
|
const chunks = runtime.channel.text.chunkMarkdownTextWithMode(replyText, textLimit, chunkMode);
|
|
1260
|
-
|
|
1261
|
-
const replySessionKey = appSessionKey ?? sessionKey;
|
|
1262
|
-
const sessionE2E = relayState.e2eSessions.get(replySessionKey);
|
|
1263
|
-
if (!sessionE2E?.ready) {
|
|
1264
|
-
ctx.log?.warn?.(
|
|
1265
|
-
`[${CHANNEL_ID}] [${accountId}] [E2E] Reply blocked: session ${replySessionKey} not ready`
|
|
1266
|
-
);
|
|
1267
|
-
return;
|
|
1268
|
-
}
|
|
1401
|
+
|
|
1269
1402
|
for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
|
|
1270
1403
|
if (!chunk) continue;
|
|
1404
|
+
const messageId = crypto.randomUUID();
|
|
1271
1405
|
const innerPayload: Record<string, unknown> = {
|
|
1272
1406
|
type: "message",
|
|
1273
1407
|
role: "assistant",
|
|
1274
1408
|
content: chunk,
|
|
1275
1409
|
sessionKey: replySessionKey,
|
|
1410
|
+
messageId,
|
|
1276
1411
|
};
|
|
1277
1412
|
if (chatSessionKey) innerPayload.chatSessionKey = chatSessionKey;
|
|
1278
1413
|
const plainMsg = JSON.stringify(innerPayload);
|
|
1279
1414
|
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
1280
1415
|
encrypted.sessionKey = replySessionKey;
|
|
1416
|
+
encrypted.messageId = messageId;
|
|
1417
|
+
if (chatSessionKey) encrypted.chatSessionKey = chatSessionKey;
|
|
1281
1418
|
const outMsg = JSON.stringify(encrypted);
|
|
1282
1419
|
relayState.ws.send(outMsg);
|
|
1283
1420
|
}
|
|
@@ -1316,6 +1453,52 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
1316
1453
|
}
|
|
1317
1454
|
}
|
|
1318
1455
|
|
|
1456
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Send a message directly to the App inbox via existing Relay/E2E.
|
|
1460
|
+
* Bypasses OpenClaw's channel outbound pipeline entirely — fully self-contained.
|
|
1461
|
+
* Returns true on success, false if no active E2E session is available.
|
|
1462
|
+
*/
|
|
1463
|
+
async function _sendToInbox(accountId: string, text: string, api: any): Promise<boolean> {
|
|
1464
|
+
const state = getRelayState(accountId);
|
|
1465
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
1466
|
+
api.logger?.warn?.(`[${CHANNEL_ID}] _sendToInbox: relay not connected`);
|
|
1467
|
+
return false;
|
|
1468
|
+
}
|
|
1469
|
+
|
|
1470
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
1471
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
1472
|
+
api.logger?.warn?.(`[${CHANNEL_ID}] _sendToInbox: no active device key`);
|
|
1473
|
+
return false;
|
|
1474
|
+
}
|
|
1475
|
+
const targetE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
1476
|
+
if (!targetE2E?.ready) {
|
|
1477
|
+
api.logger?.warn?.(`[${CHANNEL_ID}] _sendToInbox: persistent e2e session not ready`);
|
|
1478
|
+
return false;
|
|
1479
|
+
}
|
|
1480
|
+
const targetSessionKey = state.lastActiveSessionKey || "inbox-worker";
|
|
1481
|
+
|
|
1482
|
+
const messageId = crypto.randomUUID();
|
|
1483
|
+
const plainMsg = JSON.stringify({
|
|
1484
|
+
type: "message",
|
|
1485
|
+
role: "assistant",
|
|
1486
|
+
content: text,
|
|
1487
|
+
sessionKey: targetSessionKey,
|
|
1488
|
+
chatSessionKey: INBOX_CHAT_SESSION_KEY,
|
|
1489
|
+
messageId,
|
|
1490
|
+
});
|
|
1491
|
+
const encrypted = JSON.parse(await e2eEncrypt(targetE2E, plainMsg));
|
|
1492
|
+
encrypted.sessionKey = targetSessionKey;
|
|
1493
|
+
encrypted.chatSessionKey = INBOX_CHAT_SESSION_KEY;
|
|
1494
|
+
encrypted.messageId = messageId;
|
|
1495
|
+
state.ws.send(JSON.stringify(encrypted));
|
|
1496
|
+
api.logger?.info?.(`[${CHANNEL_ID}] _sendToInbox: targetSessionKey=${targetSessionKey} msgLen=${text.length} messageId=${messageId}`);
|
|
1497
|
+
return true;
|
|
1498
|
+
}
|
|
1499
|
+
|
|
1500
|
+
|
|
1501
|
+
|
|
1319
1502
|
// ── Plugin entry ─────────────────────────────────────────────────────────────
|
|
1320
1503
|
|
|
1321
1504
|
export default function register(api: any) {
|
|
@@ -1330,10 +1513,28 @@ export default function register(api: any) {
|
|
|
1330
1513
|
if (!runtime) return;
|
|
1331
1514
|
|
|
1332
1515
|
const cfg = runtime.config.loadConfig();
|
|
1516
|
+
|
|
1517
|
+
// --- Monkey patch cron.add to auto-fill delivery.to ---
|
|
1518
|
+
if (runtime.cron && typeof runtime.cron.add === "function" && !(runtime.cron.add as any).__patched) {
|
|
1519
|
+
const originalCronAdd = runtime.cron.add.bind(runtime.cron);
|
|
1520
|
+
runtime.cron.add = async (jobCreate: any) => {
|
|
1521
|
+
if (jobCreate && jobCreate.delivery && jobCreate.delivery.channel === CHANNEL_ID) {
|
|
1522
|
+
if (!jobCreate.delivery.to) {
|
|
1523
|
+
jobCreate.delivery.to = "openclaw-app-user";
|
|
1524
|
+
api.logger?.info?.(`[openclaw-app] Auto-filled delivery.to=openclaw-app-user for new cron job`);
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
return await originalCronAdd(jobCreate);
|
|
1528
|
+
};
|
|
1529
|
+
(runtime.cron.add as any).__patched = true;
|
|
1530
|
+
}
|
|
1531
|
+
|
|
1333
1532
|
const existing = cfg.channels?.[CHANNEL_ID]?.accounts?.[DEFAULT_ACCOUNT_ID];
|
|
1334
1533
|
|
|
1335
1534
|
// Only write defaults if the account entry is completely absent
|
|
1336
|
-
if (existing !== undefined)
|
|
1535
|
+
if (existing !== undefined) {
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1337
1538
|
|
|
1338
1539
|
const patched = {
|
|
1339
1540
|
...cfg,
|
|
@@ -1360,6 +1561,8 @@ export default function register(api: any) {
|
|
|
1360
1561
|
}
|
|
1361
1562
|
});
|
|
1362
1563
|
|
|
1564
|
+
|
|
1565
|
+
|
|
1363
1566
|
api.logger?.info?.("[openclaw-app] Plugin registered");
|
|
1364
1567
|
}
|
|
1365
1568
|
|
|
@@ -1386,4 +1589,4 @@ function _parseSkillFrontmatter(content: string): Record<string, any> {
|
|
|
1386
1589
|
else result[key] = val;
|
|
1387
1590
|
}
|
|
1388
1591
|
return result;
|
|
1389
|
-
}
|
|
1592
|
+
}
|