openclaw-app 1.1.7 → 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 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,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
- sendText: async ({ text, to, accountId, session }: any) => {
423
- const aid = accountId ?? session?.accountId ?? DEFAULT_ACCOUNT_ID;
424
- const state = getRelayState(aid);
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
- return { ok: false, error: "relay not connected" };
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: aid,
533
+ accountId,
437
534
  });
438
535
  outText = runtime.channel.text.convertMarkdownTables(outText, tableMode);
439
536
  }
440
537
 
441
- const replySessionKey = session?.key ?? null;
442
- if (!replySessionKey) {
443
- return { ok: false, error: "missing session key" };
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 = state.e2eSessions.get(replySessionKey);
543
+ const sessionE2E = await loadE2EStateFromPersisted(accountId, accountE2E.activeDevicePubKey);
446
544
  if (!sessionE2E?.ready) {
447
- return { ok: false, error: `e2e not ready for session ${replySessionKey}` };
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
- to: to ?? "mobile-user",
463
- messageId: `mobile-${Date.now()}`,
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
- ctx.log?.error?.(`[${CHANNEL_ID}] [${accountId}] Relay WebSocket error`);
645
- state.statusSink?.({ lastError: "WebSocket error" });
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 session joined initiate per-session E2E handshake
702
- // 프로토콜: plugin이 먼저 handshake 보내고, app이 자신의 pubkey로 응답.
703
- // peer_joined가 중복으로 있으므로 이미 진행 중인 세션은 재시작하지 않음.
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
- 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
- }
821
+ const msgTs = msg.ts as number | undefined;
747
822
 
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`);
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
- if (sessionE2E.ready) {
831
- ctx.log?.info?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} already ready, ignoring duplicate handshake`);
832
- return;
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
- if (!sessionE2E.localKeyPair) {
835
- ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} has no local keypair yet, dropping handshake`);
836
- return;
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
- 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);
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 sessionKey = msg.sessionKey as string | undefined;
853
- if (!sessionKey) {
854
- ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Encrypted msg missing sessionKey, dropping`);
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
- const sessionE2E = state.e2eSessions.get(sessionKey);
896
+
897
+ const sessionE2E = await loadE2EStateFromPersisted(accountId, devicePubKey);
858
898
  if (!sessionE2E?.ready) {
859
- ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Session ${sessionKey} not ready, dropping`);
899
+ ctx.log?.warn?.(`[${CHANNEL_ID}] [${accountId}] [E2E] Failed to load SharedSecret for active device, dropping`);
860
900
  return;
861
901
  }
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);
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
- // Plaintext message (no E2E or during handshake)
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
- 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
- }
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
- 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));
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
- : parsed?.data ?? [];
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
- // 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
- }
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) return;
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
 
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "openclaw-app",
3
3
  "name": "OpenClaw App",
4
- "version": "1.1.7",
4
+ "version": "1.1.9",
5
5
  "description": "Mobile app channel for OpenClaw — chat via the OpenClaw App app through a Cloudflare Worker relay.",
6
6
  "channels": [
7
7
  "openclaw-app"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openclaw-app",
3
- "version": "1.1.7",
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
+ }