openclaw-app 1.1.8 → 1.1.9
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/index.ts +462 -265
- 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,8 @@ 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
|
+
/** Fixed chatSessionKey that routes cron messages to the App inbox UI */
|
|
341
|
+
const INBOX_CHAT_SESSION_KEY = "inbox";
|
|
258
342
|
|
|
259
343
|
function getRelayState(accountId: string): RelayState {
|
|
260
344
|
let state = relayStates.get(accountId);
|
|
@@ -265,9 +349,6 @@ function getRelayState(accountId: string): RelayState {
|
|
|
265
349
|
pingTimer: null,
|
|
266
350
|
statusSink: null,
|
|
267
351
|
gatewayCtx: null,
|
|
268
|
-
e2eSessions: new Map(),
|
|
269
|
-
prevE2eSessions: new Map(),
|
|
270
|
-
pendingFlushQueue: new Map(),
|
|
271
352
|
relayToken: "",
|
|
272
353
|
};
|
|
273
354
|
relayStates.set(accountId, state);
|
|
@@ -415,52 +496,124 @@ const channel = {
|
|
|
415
496
|
relayUrl: account.relayUrl,
|
|
416
497
|
roomId: account.roomId,
|
|
417
498
|
}),
|
|
499
|
+
// OpenClaw's resolveOutboundTarget() falls back to this when no explicit
|
|
500
|
+
// `to` is provided in delivery config. Our channel always targets the
|
|
501
|
+
// single connected mobile user.
|
|
502
|
+
resolveDefaultTo: () => "app-user123456789",
|
|
418
503
|
},
|
|
419
504
|
outbound: {
|
|
420
505
|
deliveryMode: "direct" as const,
|
|
421
506
|
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
507
|
+
// OpenClaw's ChannelOutboundAdapter.resolveTarget
|
|
508
|
+
// Called by resolveOutboundTarget() in src/infra/outbound/targets.ts
|
|
509
|
+
// to validate/normalize the delivery target address.
|
|
510
|
+
resolveTarget: ({ to }: { to?: string; allowFrom?: string[]; accountId?: string | null; mode?: string }) => {
|
|
511
|
+
const target = to?.trim() || "app-user123456789";
|
|
512
|
+
return { ok: true as const, to: target };
|
|
513
|
+
},
|
|
514
|
+
|
|
515
|
+
// OpenClaw's ChannelOutboundAdapter.sendText
|
|
516
|
+
// Called by createPluginHandler() in src/infra/outbound/deliver.ts
|
|
517
|
+
// ctx: ChannelOutboundContext = { cfg, to, text, accountId, ... }
|
|
518
|
+
sendText: async (ctx: any) => {
|
|
519
|
+
const text = ctx.text ?? "";
|
|
520
|
+
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
521
|
+
const state = getRelayState(accountId);
|
|
425
522
|
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
426
|
-
|
|
523
|
+
throw new Error("relay not connected");
|
|
427
524
|
}
|
|
428
525
|
|
|
429
526
|
const runtime = pluginRuntime;
|
|
430
|
-
let outText = text
|
|
527
|
+
let outText = text;
|
|
431
528
|
if (runtime) {
|
|
432
529
|
const cfg = runtime.config.loadConfig();
|
|
433
530
|
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
|
434
531
|
cfg,
|
|
435
532
|
channel: CHANNEL_ID,
|
|
436
|
-
accountId
|
|
533
|
+
accountId,
|
|
437
534
|
});
|
|
438
535
|
outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
|
|
439
536
|
}
|
|
440
537
|
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
538
|
+
// Prefer the App's current active session for delivery
|
|
539
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
540
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
541
|
+
throw new Error("no active E2E session available");
|
|
444
542
|
}
|
|
445
|
-
const sessionE2E =
|
|
543
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
446
544
|
if (!sessionE2E?.ready) {
|
|
447
|
-
|
|
545
|
+
throw new Error("persistent E2E session not ready");
|
|
448
546
|
}
|
|
547
|
+
|
|
548
|
+
const replySessionKey = state.lastActiveSessionKey || "inbox-worker";
|
|
549
|
+
const chatKey = INBOX_CHAT_SESSION_KEY;
|
|
550
|
+
const messageId = `mobile-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
|
551
|
+
|
|
449
552
|
const plainMsg = JSON.stringify({
|
|
450
553
|
type: "message",
|
|
451
554
|
role: "assistant",
|
|
452
555
|
content: outText,
|
|
453
556
|
sessionKey: replySessionKey,
|
|
557
|
+
chatSessionKey: chatKey,
|
|
558
|
+
messageId,
|
|
454
559
|
});
|
|
455
560
|
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
456
561
|
encrypted.sessionKey = replySessionKey;
|
|
562
|
+
encrypted.chatSessionKey = chatKey;
|
|
563
|
+
encrypted.messageId = messageId;
|
|
457
564
|
const outMsg = JSON.stringify(encrypted);
|
|
565
|
+
|
|
566
|
+
const logger = pluginRuntime?.logger ?? console;
|
|
567
|
+
logger.info?.(`[${CHANNEL_ID}] sendText: targetSessionKey=${replySessionKey} chatKey=${chatKey} wsState=${state.ws?.readyState} msgLen=${outMsg.length}`);
|
|
568
|
+
|
|
458
569
|
state.ws.send(outMsg);
|
|
459
570
|
|
|
460
571
|
return {
|
|
461
572
|
channel: CHANNEL_ID,
|
|
462
|
-
|
|
463
|
-
|
|
573
|
+
messageId,
|
|
574
|
+
};
|
|
575
|
+
},
|
|
576
|
+
|
|
577
|
+
// OpenClaw's ChannelOutboundAdapter.sendMedia (required alongside sendText)
|
|
578
|
+
sendMedia: async (ctx: any) => {
|
|
579
|
+
// Media not supported over E2E relay — send caption text only
|
|
580
|
+
const text = ctx.text ?? "";
|
|
581
|
+
const accountId = ctx.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
582
|
+
const state = getRelayState(accountId);
|
|
583
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
584
|
+
throw new Error("relay not connected");
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
588
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
589
|
+
throw new Error("no active E2E session available");
|
|
590
|
+
}
|
|
591
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
592
|
+
if (!sessionE2E?.ready) {
|
|
593
|
+
throw new Error("persistent E2E session not ready");
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
const replySessionKey = state.lastActiveSessionKey || "inbox-worker";
|
|
597
|
+
const chatKey = INBOX_CHAT_SESSION_KEY;
|
|
598
|
+
const messageId = `mobile-${Date.now()}-${Math.floor(Math.random() * 1000)}`;
|
|
599
|
+
|
|
600
|
+
const plainMsg = JSON.stringify({
|
|
601
|
+
type: "message",
|
|
602
|
+
role: "assistant",
|
|
603
|
+
content: text || "[media]",
|
|
604
|
+
sessionKey: replySessionKey,
|
|
605
|
+
chatSessionKey: chatKey,
|
|
606
|
+
messageId,
|
|
607
|
+
});
|
|
608
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
609
|
+
encrypted.sessionKey = replySessionKey;
|
|
610
|
+
encrypted.chatSessionKey = chatKey;
|
|
611
|
+
encrypted.messageId = messageId;
|
|
612
|
+
state.ws.send(JSON.stringify(encrypted));
|
|
613
|
+
|
|
614
|
+
return {
|
|
615
|
+
channel: CHANNEL_ID,
|
|
616
|
+
messageId: `mobile-media-${Date.now()}`,
|
|
464
617
|
};
|
|
465
618
|
},
|
|
466
619
|
},
|
|
@@ -559,13 +712,9 @@ function cleanupRelay(state: RelayState) {
|
|
|
559
712
|
state.reconnectTimer = null;
|
|
560
713
|
}
|
|
561
714
|
if (state.ws) {
|
|
562
|
-
try { state.ws.close(); } catch {}
|
|
715
|
+
try { state.ws.close(); } catch { }
|
|
563
716
|
state.ws = null;
|
|
564
717
|
}
|
|
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
718
|
}
|
|
570
719
|
|
|
571
720
|
function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
@@ -624,9 +773,10 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
|
624
773
|
});
|
|
625
774
|
});
|
|
626
775
|
|
|
627
|
-
state.ws.addEventListener("close", () => {
|
|
776
|
+
state.ws.addEventListener("close", (event: any) => {
|
|
777
|
+
if (state.ws !== null && state.ws !== event.target) return; // Ignore stale connections
|
|
628
778
|
ctx.log?.info?.(
|
|
629
|
-
`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting
|
|
779
|
+
`[${CHANNEL_ID}] [${accountId}] Relay disconnected, reconnecting... (code: ${event.code}, reason: ${event.reason})`
|
|
630
780
|
);
|
|
631
781
|
state.ws = null;
|
|
632
782
|
if (state.pingTimer) {
|
|
@@ -640,9 +790,10 @@ function connectRelay(ctx: any, account: ResolvedAccount) {
|
|
|
640
790
|
scheduleReconnect(ctx, account);
|
|
641
791
|
});
|
|
642
792
|
|
|
643
|
-
state.ws.addEventListener("error", () => {
|
|
644
|
-
|
|
645
|
-
|
|
793
|
+
state.ws.addEventListener("error", (event: any) => {
|
|
794
|
+
const errMsg = event?.message || String(event);
|
|
795
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Relay WebSocket error: ${errMsg}`);
|
|
796
|
+
state.statusSink?.({ lastError: `WebSocket error: ${errMsg}` });
|
|
646
797
|
});
|
|
647
798
|
}
|
|
648
799
|
|
|
@@ -655,41 +806,6 @@ function scheduleReconnect(ctx: any, account: ResolvedAccount) {
|
|
|
655
806
|
}, RECONNECT_DELAY);
|
|
656
807
|
}
|
|
657
808
|
|
|
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
809
|
async function handleRelayMessage(ctx: any, accountId: string, state: RelayState, raw: string): Promise<void> {
|
|
694
810
|
// Skip ping/pong
|
|
695
811
|
if (raw === "ping" || raw === "pong") return;
|
|
@@ -698,77 +814,22 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
698
814
|
|
|
699
815
|
const msg = JSON.parse(raw);
|
|
700
816
|
|
|
701
|
-
// App
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
if (msg.type === "peer_joined") {
|
|
817
|
+
// App's handshake request (V2: App initiates with its persistent PubKey)
|
|
818
|
+
if (msg.type === "handshake") {
|
|
819
|
+
const peerPubKey = msg.pubkey as string | undefined;
|
|
705
820
|
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
|
-
}
|
|
821
|
+
const msgTs = msg.ts as number | undefined;
|
|
747
822
|
|
|
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`);
|
|
823
|
+
if (!peerPubKey) {
|
|
824
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] handshake missing pubkey, ignoring`);
|
|
754
825
|
return;
|
|
755
826
|
}
|
|
756
|
-
await processPendingFlush(ctx, accountId, state, sessionKey, messages);
|
|
757
|
-
return;
|
|
758
|
-
}
|
|
759
827
|
|
|
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
828
|
if (state.relayToken) {
|
|
829
|
+
const peerMac = msg.mac as string | undefined;
|
|
830
|
+
const peerTs = msg.ts as number | undefined;
|
|
831
|
+
const version = msg.v as string | undefined;
|
|
832
|
+
|
|
772
833
|
if (!peerMac || peerTs == null) {
|
|
773
834
|
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake missing auth fields`);
|
|
774
835
|
return;
|
|
@@ -781,104 +842,88 @@ async function handleRelayMessage(ctx: any, accountId: string, state: RelayState
|
|
|
781
842
|
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake timestamp out of window, dropping`);
|
|
782
843
|
return;
|
|
783
844
|
}
|
|
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
|
-
}
|
|
798
|
-
let sessionE2E = state.e2eSessions.get(sessionKey);
|
|
799
|
-
if (!sessionE2E) {
|
|
800
|
-
// peer_joined 없이 app이 먼저 handshake를 보낸 경우 (예: plugin 재연결)
|
|
801
|
-
// 새 세션을 만들고 plugin의 pubkey를 먼저 전송한 후 ECDH 완성
|
|
802
|
-
sessionE2E = makeE2EState();
|
|
803
|
-
state.e2eSessions.set(sessionKey, sessionE2E);
|
|
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
845
|
}
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
846
|
+
|
|
847
|
+
// V2: Derive persistent shared secret and store it
|
|
848
|
+
await e2eHandleHandshake(accountId, peerPubKey);
|
|
849
|
+
|
|
850
|
+
if (sessionKey) {
|
|
851
|
+
state.lastActiveSessionKey = sessionKey;
|
|
833
852
|
}
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
853
|
+
|
|
854
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Handshake handled for app pubkey ${peerPubKey.slice(0, 8)}...`);
|
|
855
|
+
|
|
856
|
+
// Reply with our persistent pubkey (Plugin PubKey) so App knows it
|
|
857
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
858
|
+
const replyPayload: Record<string, unknown> = {
|
|
859
|
+
type: "handshake",
|
|
860
|
+
sessionKey: sessionKey || "",
|
|
861
|
+
pubkey: accountE2E.pluginPubB64,
|
|
862
|
+
};
|
|
863
|
+
|
|
864
|
+
if (state.relayToken) {
|
|
865
|
+
const ts = Date.now();
|
|
866
|
+
const mac = await buildHandshakeMac(
|
|
867
|
+
state.relayToken,
|
|
868
|
+
"plugin",
|
|
869
|
+
sessionKey || "",
|
|
870
|
+
accountE2E.pluginPubB64,
|
|
871
|
+
ts,
|
|
872
|
+
HANDSHAKE_AUTH_VERSION
|
|
873
|
+
);
|
|
874
|
+
replyPayload.v = HANDSHAKE_AUTH_VERSION;
|
|
875
|
+
replyPayload.ts = ts;
|
|
876
|
+
replyPayload.mac = mac;
|
|
837
877
|
}
|
|
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);
|
|
878
|
+
|
|
879
|
+
if (state.ws?.readyState === WebSocket.OPEN) {
|
|
880
|
+
state.ws.send(JSON.stringify(replyPayload));
|
|
846
881
|
}
|
|
847
882
|
return;
|
|
848
883
|
}
|
|
849
|
-
|
|
850
|
-
// E2E encrypted message — decrypt using the per-session key
|
|
884
|
+
// Process E2E encrypted messages from the App
|
|
851
885
|
if (msg.type === "encrypted") {
|
|
852
|
-
const
|
|
853
|
-
|
|
854
|
-
|
|
886
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
887
|
+
|
|
888
|
+
// V2.1: Use the explicit explicit device pubkey embedded in the envelope, otherwise
|
|
889
|
+
// fallback to the connection activeDevicePubKey handling (V2/legacy)
|
|
890
|
+
const devicePubKey = msg.pubkey || accountE2E.activeDevicePubKey;
|
|
891
|
+
|
|
892
|
+
if (!devicePubKey) {
|
|
893
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] No active device key, dropping encrypted message`);
|
|
855
894
|
return;
|
|
856
895
|
}
|
|
857
|
-
|
|
896
|
+
|
|
897
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, devicePubKey);
|
|
858
898
|
if (!sessionE2E?.ready) {
|
|
859
|
-
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E]
|
|
899
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Failed to load SharedSecret for active device, dropping`);
|
|
860
900
|
return;
|
|
861
901
|
}
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
902
|
+
|
|
903
|
+
try {
|
|
904
|
+
const plaintext = await e2eDecrypt(sessionE2E, msg.nonce, msg.ct);
|
|
905
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Decrypted message: ${plaintext.slice(0, 200)}`);
|
|
906
|
+
const innerMsg = JSON.parse(plaintext);
|
|
907
|
+
await handleInbound(ctx, accountId, innerMsg);
|
|
908
|
+
} catch (e) {
|
|
909
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Decryption failed: ${e}`);
|
|
910
|
+
}
|
|
868
911
|
return;
|
|
869
912
|
}
|
|
870
913
|
|
|
871
|
-
//
|
|
914
|
+
// Drop any plaintext message that sneaks through (only handshake/encrypted should be processed)
|
|
872
915
|
if (msg.type === "message" || msg.type === "delta" || msg.type === "final" || msg.type === "abort") {
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
916
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Dropping plaintext ${msg.type} message for security`);
|
|
917
|
+
return;
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Silently ignore relay system events that don't need action on the plugin side
|
|
921
|
+
if (msg.type === "peer_joined" || msg.type === "peer_left") {
|
|
922
|
+
ctx.log?.debug?.(`[${CHANNEL_ID}] [${accountId}] Relay system event ignored: ${msg.type}`);
|
|
923
|
+
return;
|
|
881
924
|
}
|
|
925
|
+
|
|
926
|
+
// Fallback for any other system message
|
|
882
927
|
await handleInbound(ctx, accountId, msg);
|
|
883
928
|
}
|
|
884
929
|
|
|
@@ -902,12 +947,22 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
902
947
|
const sendRpcReply = async (data: unknown, error?: string) => {
|
|
903
948
|
if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN) return;
|
|
904
949
|
if (!replySessionKey) return;
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
950
|
+
|
|
951
|
+
try {
|
|
952
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
953
|
+
if (!accountE2E.activeDevicePubKey) return;
|
|
954
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
955
|
+
if (!sessionE2E?.ready) return;
|
|
956
|
+
|
|
957
|
+
const msgId = crypto.randomUUID();
|
|
958
|
+
const inner = JSON.stringify({ type: "rpc-response", id: reqId, data, error: error ?? null, sessionKey: replySessionKey, messageId: msgId });
|
|
959
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, inner));
|
|
960
|
+
encrypted.sessionKey = replySessionKey;
|
|
961
|
+
encrypted.messageId = msgId;
|
|
962
|
+
relayState.ws.send(JSON.stringify(encrypted));
|
|
963
|
+
} catch (e) {
|
|
964
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] sendRpcReply fail: ${e}`);
|
|
965
|
+
}
|
|
911
966
|
};
|
|
912
967
|
|
|
913
968
|
try {
|
|
@@ -972,7 +1027,7 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
972
1027
|
entriesSample: Object.entries(cfg?.skills?.entries ?? {}).slice(0, 5)
|
|
973
1028
|
.map(([k, v]: [string, any]) => ({ name: k, enabled: v?.enabled })),
|
|
974
1029
|
};
|
|
975
|
-
} catch (_) {}
|
|
1030
|
+
} catch (_) { }
|
|
976
1031
|
|
|
977
1032
|
await sendRpcReply({ runtimeShape, skillsProbe, cfgSkills });
|
|
978
1033
|
} else if (method === "agents.list") {
|
|
@@ -1039,11 +1094,11 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1039
1094
|
10000
|
|
1040
1095
|
);
|
|
1041
1096
|
}
|
|
1042
|
-
if (cliResult.exitCode === 0 && cliResult.stdout?.trim()) {
|
|
1097
|
+
if (cliResult && cliResult.exitCode === 0 && cliResult.stdout?.trim()) {
|
|
1043
1098
|
const parsed = JSON.parse(cliResult.stdout.trim());
|
|
1044
1099
|
const raw: any[] = Array.isArray(parsed) ? parsed
|
|
1045
1100
|
: Array.isArray(parsed?.skills) ? parsed.skills
|
|
1046
|
-
|
|
1101
|
+
: parsed?.data ?? [];
|
|
1047
1102
|
tools = raw
|
|
1048
1103
|
.filter((s: any) => s['user-invocable'] !== false && s['user-invocable'] !== 'false')
|
|
1049
1104
|
.map((s: any) => ({
|
|
@@ -1054,7 +1109,7 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1054
1109
|
}))
|
|
1055
1110
|
.filter((t: any) => t.name);
|
|
1056
1111
|
}
|
|
1057
|
-
} catch (_) {}
|
|
1112
|
+
} catch (_) { }
|
|
1058
1113
|
|
|
1059
1114
|
// Fallback: scan ~/.openclaw for all skills/ subdirs, deduplicated
|
|
1060
1115
|
if (tools.length === 0) {
|
|
@@ -1073,7 +1128,7 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1073
1128
|
const addSkillsDir = (dir: string) => {
|
|
1074
1129
|
try {
|
|
1075
1130
|
if (fs.existsSync(dir)) skillDirs.push(dir);
|
|
1076
|
-
} catch (_) {}
|
|
1131
|
+
} catch (_) { }
|
|
1077
1132
|
};
|
|
1078
1133
|
|
|
1079
1134
|
// ~/.openclaw/skills
|
|
@@ -1085,7 +1140,7 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1085
1140
|
const sub = path.join(ocRoot, entry, 'skills');
|
|
1086
1141
|
addSkillsDir(sub);
|
|
1087
1142
|
}
|
|
1088
|
-
} catch (_) {}
|
|
1143
|
+
} catch (_) { }
|
|
1089
1144
|
|
|
1090
1145
|
// Extra dirs from config
|
|
1091
1146
|
for (const d of (cfg?.skills?.load?.extraDirs ?? [])) addSkillsDir(d);
|
|
@@ -1107,10 +1162,15 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1107
1162
|
tools.push({ name, description: fm.description ?? '', category: '', userInvocable: true });
|
|
1108
1163
|
}
|
|
1109
1164
|
}
|
|
1110
|
-
} catch (_) {}
|
|
1165
|
+
} catch (_) { }
|
|
1111
1166
|
}
|
|
1112
1167
|
|
|
1113
1168
|
await sendRpcReply({ tools });
|
|
1169
|
+
} else if (method === "inbox.test") {
|
|
1170
|
+
// Debug/test: send a test message to the App inbox
|
|
1171
|
+
const testText = (params.text as string) || "🔔 Inbox test — if you see this, the pipeline works!";
|
|
1172
|
+
const sent = await _sendToInbox(accountId, testText, { logger: ctx.log });
|
|
1173
|
+
await sendRpcReply({ sent, message: sent ? "delivered" : "no active E2E session" });
|
|
1114
1174
|
} else if (method === "tools.invoke") {
|
|
1115
1175
|
// Invoke a skill by injecting a /skill <name> command into the chat session
|
|
1116
1176
|
const toolName = params.name as string | undefined;
|
|
@@ -1133,6 +1193,25 @@ async function handleRpc(ctx: any, accountId: string, msg: any): Promise<boolean
|
|
|
1133
1193
|
await sendRpcReply(null, `tools.invoke '${toolName}' error: ${e}`);
|
|
1134
1194
|
}
|
|
1135
1195
|
}
|
|
1196
|
+
} else if (method === "chat.abort") {
|
|
1197
|
+
const abortSessionKey = (params.sessionKey as string) || replySessionKey;
|
|
1198
|
+
const runId = params.runId as string | undefined;
|
|
1199
|
+
if (!abortSessionKey) {
|
|
1200
|
+
await sendRpcReply(null, "chat.abort: missing required param 'sessionKey'");
|
|
1201
|
+
} else {
|
|
1202
|
+
try {
|
|
1203
|
+
// Signal the reply engine to abort any active generation for this session/runId
|
|
1204
|
+
if (typeof runtime.channel.reply.abortDispatch === "function") {
|
|
1205
|
+
await runtime.channel.reply.abortDispatch(abortSessionKey, runId);
|
|
1206
|
+
await sendRpcReply({ aborted: true, sessionKey: abortSessionKey, runId });
|
|
1207
|
+
ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] Aborted chat dispatch for session ${abortSessionKey} (runId: ${runId})`);
|
|
1208
|
+
} else {
|
|
1209
|
+
await sendRpcReply(null, "chat.abort is not supported by this OpenClaw version");
|
|
1210
|
+
}
|
|
1211
|
+
} catch (e: any) {
|
|
1212
|
+
await sendRpcReply(null, `chat.abort error: ${e}`);
|
|
1213
|
+
}
|
|
1214
|
+
}
|
|
1136
1215
|
} else {
|
|
1137
1216
|
await sendRpcReply(null, `Unknown RPC method: ${method}`);
|
|
1138
1217
|
}
|
|
@@ -1246,38 +1325,90 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
1246
1325
|
const { dispatcher, replyOptions, markDispatchIdle } =
|
|
1247
1326
|
runtime.channel.reply.createReplyDispatcherWithTyping({
|
|
1248
1327
|
humanDelay: runtime.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
|
|
1328
|
+
onTyping: async () => {
|
|
1329
|
+
const relayState = getRelayState(accountId);
|
|
1330
|
+
if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN) return;
|
|
1331
|
+
|
|
1332
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
1333
|
+
if (!accountE2E.activeDevicePubKey) return;
|
|
1334
|
+
|
|
1335
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
1336
|
+
if (!sessionE2E?.ready) return;
|
|
1337
|
+
|
|
1338
|
+
const replySessionKey = appSessionKey ?? sessionKey;
|
|
1339
|
+
const innerPayload: Record<string, unknown> = {
|
|
1340
|
+
type: "typing",
|
|
1341
|
+
sessionKey: replySessionKey,
|
|
1342
|
+
};
|
|
1343
|
+
if (chatSessionKey) innerPayload.chatSessionKey = chatSessionKey;
|
|
1344
|
+
|
|
1345
|
+
try {
|
|
1346
|
+
const plainMsg = JSON.stringify(innerPayload);
|
|
1347
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
1348
|
+
encrypted.sessionKey = replySessionKey;
|
|
1349
|
+
if (chatSessionKey) encrypted.chatSessionKey = chatSessionKey;
|
|
1350
|
+
relayState.ws.send(JSON.stringify(encrypted));
|
|
1351
|
+
} catch (e) {
|
|
1352
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Failed to send typing: ${e}`);
|
|
1353
|
+
}
|
|
1354
|
+
},
|
|
1249
1355
|
deliver: async (payload: any) => {
|
|
1250
1356
|
const relayState = getRelayState(accountId);
|
|
1251
1357
|
if (!relayState.ws || relayState.ws.readyState !== WebSocket.OPEN) {
|
|
1252
1358
|
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Cannot deliver reply: relay not connected`);
|
|
1253
1359
|
return;
|
|
1254
1360
|
}
|
|
1361
|
+
|
|
1362
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
1363
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
1364
|
+
ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Reply blocked: no active device key for account`);
|
|
1365
|
+
return;
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
1369
|
+
if (!sessionE2E?.ready) {
|
|
1370
|
+
ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Reply blocked: persistent session not ready`);
|
|
1371
|
+
return;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
const replySessionKey = appSessionKey ?? sessionKey;
|
|
1375
|
+
|
|
1376
|
+
if (payload.type === "typing") {
|
|
1377
|
+
const innerPayload: Record<string, unknown> = {
|
|
1378
|
+
type: "typing",
|
|
1379
|
+
sessionKey: replySessionKey,
|
|
1380
|
+
};
|
|
1381
|
+
if (chatSessionKey) innerPayload.chatSessionKey = chatSessionKey;
|
|
1382
|
+
const plainMsg = JSON.stringify(innerPayload);
|
|
1383
|
+
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
1384
|
+
encrypted.sessionKey = replySessionKey;
|
|
1385
|
+
if (chatSessionKey) encrypted.chatSessionKey = chatSessionKey;
|
|
1386
|
+
relayState.ws.send(JSON.stringify(encrypted));
|
|
1387
|
+
return;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1255
1390
|
const replyText = runtime.channel.text.convertMarkdownTables(
|
|
1256
1391
|
payload.text ?? "", tableMode,
|
|
1257
1392
|
);
|
|
1258
1393
|
const chunkMode = runtime.channel.text.resolveChunkMode(cfg, CHANNEL_ID, accountId);
|
|
1259
1394
|
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
|
-
}
|
|
1395
|
+
|
|
1269
1396
|
for (const chunk of chunks.length > 0 ? chunks : [replyText]) {
|
|
1270
1397
|
if (!chunk) continue;
|
|
1398
|
+
const messageId = crypto.randomUUID();
|
|
1271
1399
|
const innerPayload: Record<string, unknown> = {
|
|
1272
1400
|
type: "message",
|
|
1273
1401
|
role: "assistant",
|
|
1274
1402
|
content: chunk,
|
|
1275
1403
|
sessionKey: replySessionKey,
|
|
1404
|
+
messageId,
|
|
1276
1405
|
};
|
|
1277
1406
|
if (chatSessionKey) innerPayload.chatSessionKey = chatSessionKey;
|
|
1278
1407
|
const plainMsg = JSON.stringify(innerPayload);
|
|
1279
1408
|
const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, plainMsg));
|
|
1280
1409
|
encrypted.sessionKey = replySessionKey;
|
|
1410
|
+
encrypted.messageId = messageId;
|
|
1411
|
+
if (chatSessionKey) encrypted.chatSessionKey = chatSessionKey;
|
|
1281
1412
|
const outMsg = JSON.stringify(encrypted);
|
|
1282
1413
|
relayState.ws.send(outMsg);
|
|
1283
1414
|
}
|
|
@@ -1316,6 +1447,52 @@ async function handleInbound(ctx: any, accountId: string, msg: any) {
|
|
|
1316
1447
|
}
|
|
1317
1448
|
}
|
|
1318
1449
|
|
|
1450
|
+
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
1451
|
+
|
|
1452
|
+
/**
|
|
1453
|
+
* Send a message directly to the App inbox via existing Relay/E2E.
|
|
1454
|
+
* Bypasses OpenClaw's channel outbound pipeline entirely — fully self-contained.
|
|
1455
|
+
* Returns true on success, false if no active E2E session is available.
|
|
1456
|
+
*/
|
|
1457
|
+
async function _sendToInbox(accountId: string, text: string, api: any): Promise<boolean> {
|
|
1458
|
+
const state = getRelayState(accountId);
|
|
1459
|
+
if (!state.ws || state.ws.readyState !== WebSocket.OPEN) {
|
|
1460
|
+
api.logger?.warn?.(`[${CHANNEL_ID}] _sendToInbox: relay not connected`);
|
|
1461
|
+
return false;
|
|
1462
|
+
}
|
|
1463
|
+
|
|
1464
|
+
const accountE2E = await getOrInitAccountE2E(accountId);
|
|
1465
|
+
if (!accountE2E.activeDevicePubKey) {
|
|
1466
|
+
api.logger?.warn?.(`[${CHANNEL_ID}] _sendToInbox: no active device key`);
|
|
1467
|
+
return false;
|
|
1468
|
+
}
|
|
1469
|
+
const targetE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
|
|
1470
|
+
if (!targetE2E?.ready) {
|
|
1471
|
+
api.logger?.warn?.(`[${CHANNEL_ID}] _sendToInbox: persistent e2e session not ready`);
|
|
1472
|
+
return false;
|
|
1473
|
+
}
|
|
1474
|
+
const targetSessionKey = state.lastActiveSessionKey || "inbox-worker";
|
|
1475
|
+
|
|
1476
|
+
const messageId = crypto.randomUUID();
|
|
1477
|
+
const plainMsg = JSON.stringify({
|
|
1478
|
+
type: "message",
|
|
1479
|
+
role: "assistant",
|
|
1480
|
+
content: text,
|
|
1481
|
+
sessionKey: targetSessionKey,
|
|
1482
|
+
chatSessionKey: INBOX_CHAT_SESSION_KEY,
|
|
1483
|
+
messageId,
|
|
1484
|
+
});
|
|
1485
|
+
const encrypted = JSON.parse(await e2eEncrypt(targetE2E, plainMsg));
|
|
1486
|
+
encrypted.sessionKey = targetSessionKey;
|
|
1487
|
+
encrypted.chatSessionKey = INBOX_CHAT_SESSION_KEY;
|
|
1488
|
+
encrypted.messageId = messageId;
|
|
1489
|
+
state.ws.send(JSON.stringify(encrypted));
|
|
1490
|
+
api.logger?.info?.(`[${CHANNEL_ID}] _sendToInbox: targetSessionKey=${targetSessionKey} msgLen=${text.length} messageId=${messageId}`);
|
|
1491
|
+
return true;
|
|
1492
|
+
}
|
|
1493
|
+
|
|
1494
|
+
|
|
1495
|
+
|
|
1319
1496
|
// ── Plugin entry ─────────────────────────────────────────────────────────────
|
|
1320
1497
|
|
|
1321
1498
|
export default function register(api: any) {
|
|
@@ -1330,10 +1507,28 @@ export default function register(api: any) {
|
|
|
1330
1507
|
if (!runtime) return;
|
|
1331
1508
|
|
|
1332
1509
|
const cfg = runtime.config.loadConfig();
|
|
1510
|
+
|
|
1511
|
+
// --- Monkey patch cron.add to auto-fill delivery.to ---
|
|
1512
|
+
if (runtime.cron && typeof runtime.cron.add === "function" && !(runtime.cron.add as any).__patched) {
|
|
1513
|
+
const originalCronAdd = runtime.cron.add.bind(runtime.cron);
|
|
1514
|
+
runtime.cron.add = async (jobCreate: any) => {
|
|
1515
|
+
if (jobCreate && jobCreate.delivery && jobCreate.delivery.channel === CHANNEL_ID) {
|
|
1516
|
+
if (!jobCreate.delivery.to) {
|
|
1517
|
+
jobCreate.delivery.to = "app-user123456789";
|
|
1518
|
+
api.logger?.info?.(`[openclaw-app] Auto-filled delivery.to=app-user123456789 for new cron job`);
|
|
1519
|
+
}
|
|
1520
|
+
}
|
|
1521
|
+
return await originalCronAdd(jobCreate);
|
|
1522
|
+
};
|
|
1523
|
+
(runtime.cron.add as any).__patched = true;
|
|
1524
|
+
}
|
|
1525
|
+
|
|
1333
1526
|
const existing = cfg.channels?.[CHANNEL_ID]?.accounts?.[DEFAULT_ACCOUNT_ID];
|
|
1334
1527
|
|
|
1335
1528
|
// Only write defaults if the account entry is completely absent
|
|
1336
|
-
if (existing !== undefined)
|
|
1529
|
+
if (existing !== undefined) {
|
|
1530
|
+
return;
|
|
1531
|
+
}
|
|
1337
1532
|
|
|
1338
1533
|
const patched = {
|
|
1339
1534
|
...cfg,
|
|
@@ -1360,6 +1555,8 @@ export default function register(api: any) {
|
|
|
1360
1555
|
}
|
|
1361
1556
|
});
|
|
1362
1557
|
|
|
1558
|
+
|
|
1559
|
+
|
|
1363
1560
|
api.logger?.info?.("[openclaw-app] Plugin registered");
|
|
1364
1561
|
}
|
|
1365
1562
|
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-app",
|
|
3
|
-
"version": "1.1.
|
|
3
|
+
"version": "1.1.9",
|
|
4
4
|
"description": "OpenClaw App channel plugin — relay bridge for the OpenClaw App app",
|
|
5
5
|
"main": "index.ts",
|
|
6
6
|
"type": "module",
|
|
@@ -27,5 +27,8 @@
|
|
|
27
27
|
"openclaw": {
|
|
28
28
|
"optional": true
|
|
29
29
|
}
|
|
30
|
+
},
|
|
31
|
+
"dependencies": {
|
|
32
|
+
"ws": "^8.19.0"
|
|
30
33
|
}
|
|
31
|
-
}
|
|
34
|
+
}
|