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/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
- async function e2eHandleHandshake(state: E2EState, peerPubKeyB64: string): Promise<void> {
68
- if (!state.localKeyPair) throw new Error("[E2E] Must call e2eInit first");
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.buffer as ArrayBuffer,
162
+ peerPubKeyBytes,
75
163
  algo.imp,
76
164
  false,
77
165
  []
78
166
  );
79
167
 
80
- const ecdhRawKey = await crypto.subtle.deriveKey(
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
- state.localKeyPair.privateKey,
83
- { name: "AES-GCM", length: 256 },
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 as ArrayBuffer, { name: "HKDF" }, false, ["deriveKey"]
178
+ "raw", ecdhRawBytes, { name: "HKDF" }, false, ["deriveKey"]
92
179
  );
93
- state.sharedKey = await crypto.subtle.deriveKey(
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
- false,
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.buffer as ArrayBuffer },
224
+ { name: "AES-GCM", iv: nonce },
130
225
  state.sharedKey,
131
- ct.buffer as ArrayBuffer
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 bytes = buf instanceof Uint8Array ? buf : new Uint8Array(buf);
138
- let binary = "";
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 pad = (4 - padded.length % 4) % 4;
146
- const binary = atob(padded + "=".repeat(pad));
147
- const bytes = new Uint8Array(binary.length);
148
- for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
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
- sendText: async ({ text, to, accountId, session }: any) => {
423
- const aid = accountId ?? session?.accountId ?? DEFAULT_ACCOUNT_ID;
424
- const state = getRelayState(aid);
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
- return { ok: false, error: "relay not connected" };
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: aid,
534
+ accountId,
437
535
  });
438
536
  outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
439
537
  }
440
538
 
441
- const replySessionKey = session?.key ?? null;
442
- if (!replySessionKey) {
443
- return { ok: false, error: "missing session key" };
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 = state.e2eSessions.get(replySessionKey);
544
+ const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
446
545
  if (!sessionE2E?.ready) {
447
- return { ok: false, error: `e2e not ready for session ${replySessionKey}` };
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
- to: to ?? "mobile-user",
463
- messageId: `mobile-${Date.now()}`,
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
- ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Relay WebSocket error`);
645
- state.statusSink?.({ lastError: "WebSocket error" });
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 session joined initiate per-session E2E handshake
702
- // 프로토콜: plugin이 먼저 handshake 보내고, app이 자신의 pubkey로 응답.
703
- // peer_joined가 중복으로 있으므로 이미 진행 중인 세션은 재시작하지 않음.
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
- if (!sessionKey) {
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
- // Relay forwards buffered offline messages for re-encryption.
749
- if (msg.type === "pending_flush") {
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
- 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
- }
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
- if (!sessionE2E.localKeyPair) {
835
- ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} has no local keypair yet, dropping handshake`);
836
- return;
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
- await e2eHandleHandshake(sessionE2E, peerPubKey);
839
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} handshake complete`);
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 sessionKey = msg.sessionKey as string | undefined;
853
- if (!sessionKey) {
854
- ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Encrypted msg missing sessionKey, dropping`);
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
- const sessionE2E = state.e2eSessions.get(sessionKey);
897
+
898
+ const sessionE2E = await loadE2EStateFromPersisted(accountId, devicePubKey);
858
899
  if (!sessionE2E?.ready) {
859
- ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} not ready, dropping`);
900
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Failed to load SharedSecret for active device, dropping`);
860
901
  return;
861
902
  }
862
- const plaintext = await e2eDecrypt(sessionE2E, msg.nonce, msg.ct);
863
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} decrypted: ${plaintext.slice(0, 200)}`);
864
- const innerMsg = JSON.parse(plaintext);
865
- // Preserve sessionKey through decryption so handleInbound can use it
866
- if (!innerMsg.sessionKey) innerMsg.sessionKey = sessionKey;
867
- await handleInbound(ctx, accountId, innerMsg);
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
- // Plaintext message (no E2E or during handshake)
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
- const sessionKey = msg.sessionKey as string | undefined;
874
- const sessionE2E = sessionKey ? state.e2eSessions.get(sessionKey) : undefined;
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
- const sessionE2E = relayState.e2eSessions.get(replySessionKey);
906
- if (!sessionE2E?.ready) return;
907
- const inner = JSON.stringify({ type: "rpc-response", id: reqId, data, error: error ?? null, sessionKey: replySessionKey });
908
- const encrypted = JSON.parse(await e2eEncrypt(sessionE2E, inner));
909
- encrypted.sessionKey = replySessionKey;
910
- relayState.ws.send(JSON.stringify(encrypted));
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
- : parsed?.data ?? [];
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 ?? "mobile-user";
1167
- const senderName = msg.senderName ?? "Mobile User";
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
- // The relay routes plugin→app by sessionKey in the JSON payload
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) return;
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
+ }