botschat 0.1.6 → 0.1.7

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.
Files changed (43) hide show
  1. package/README.md +62 -22
  2. package/migrations/0011_e2e_encryption.sql +35 -0
  3. package/package.json +4 -2
  4. package/packages/api/src/do/connection-do.ts +34 -9
  5. package/packages/api/src/index.ts +29 -7
  6. package/packages/api/src/routes/auth.ts +4 -1
  7. package/packages/api/src/routes/setup.ts +2 -0
  8. package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
  9. package/packages/plugin/dist/src/accounts.js +1 -0
  10. package/packages/plugin/dist/src/accounts.js.map +1 -1
  11. package/packages/plugin/dist/src/channel.d.ts +1 -0
  12. package/packages/plugin/dist/src/channel.d.ts.map +1 -1
  13. package/packages/plugin/dist/src/channel.js +142 -6
  14. package/packages/plugin/dist/src/channel.js.map +1 -1
  15. package/packages/plugin/dist/src/types.d.ts +16 -0
  16. package/packages/plugin/dist/src/types.d.ts.map +1 -1
  17. package/packages/plugin/dist/src/ws-client.d.ts +2 -0
  18. package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
  19. package/packages/plugin/dist/src/ws-client.js +14 -3
  20. package/packages/plugin/dist/src/ws-client.js.map +1 -1
  21. package/packages/plugin/package.json +3 -2
  22. package/packages/web/dist/architecture.png +0 -0
  23. package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
  24. package/packages/web/dist/assets/{index-BST9bfvT.css → index-ewBIratI.css} +1 -1
  25. package/packages/web/dist/index.html +2 -2
  26. package/packages/web/package.json +1 -0
  27. package/packages/web/src/App.tsx +46 -8
  28. package/packages/web/src/analytics.ts +57 -0
  29. package/packages/web/src/api.ts +4 -0
  30. package/packages/web/src/components/ConnectionSettings.tsx +3 -1
  31. package/packages/web/src/components/E2ESettings.tsx +122 -0
  32. package/packages/web/src/components/IconRail.tsx +1 -12
  33. package/packages/web/src/components/LoginPage.tsx +19 -3
  34. package/packages/web/src/components/OnboardingPage.tsx +152 -5
  35. package/packages/web/src/e2e.ts +133 -0
  36. package/packages/web/src/main.tsx +3 -0
  37. package/packages/web/src/store.ts +4 -3
  38. package/packages/web/src/ws.ts +76 -4
  39. package/scripts/dev.sh +5 -5
  40. package/scripts/test-e2e-live.ts +194 -0
  41. package/scripts/verify-e2e-db.ts +48 -0
  42. package/scripts/verify-e2e.ts +56 -0
  43. package/packages/web/dist/assets/index-Da18EnTa.js +0 -851
@@ -49,6 +49,8 @@ export function LoginPage() {
49
49
  }, [firebaseEnabled]);
50
50
 
51
51
  const emailEnabled = authConfig?.emailEnabled ?? true;
52
+ const configLoaded = authConfig !== null;
53
+ const hasAnyLoginMethod = configLoaded && (firebaseEnabled || emailEnabled);
52
54
 
53
55
  const handleAuthSuccess = (res: { id: string; email: string; displayName?: string; token: string; refreshToken?: string }) => {
54
56
  setToken(res.token);
@@ -148,8 +150,22 @@ export function LoginPage() {
148
150
  : "Sign in"}
149
151
  </h2>
150
152
 
153
+ {/* Loading: avoid showing empty card on first paint before config is loaded */}
154
+ {!configLoaded && (
155
+ <div className="py-8 text-center" style={{ color: "var(--text-muted)" }}>
156
+ <span className="text-body">Loading sign-in options…</span>
157
+ </div>
158
+ )}
159
+
160
+ {/* No methods available (e.g. misconfiguration) */}
161
+ {configLoaded && !hasAnyLoginMethod && (
162
+ <div className="py-4 text-caption" style={{ color: "var(--text-secondary)" }}>
163
+ Sign-in is not configured. Please contact support.
164
+ </div>
165
+ )}
166
+
151
167
  {/* OAuth buttons */}
152
- {firebaseEnabled && (
168
+ {configLoaded && firebaseEnabled && (
153
169
  <>
154
170
  <div className="space-y-3">
155
171
  {/* Google */}
@@ -198,7 +214,7 @@ export function LoginPage() {
198
214
  </div>
199
215
 
200
216
  {/* Divider — only show if email login is also available */}
201
- {emailEnabled && (
217
+ {configLoaded && emailEnabled && (
202
218
  <div className="flex items-center gap-3 my-5">
203
219
  <div className="flex-1 h-px" style={{ background: "var(--border)" }} />
204
220
  <span className="text-caption" style={{ color: "var(--text-muted)" }}>
@@ -221,7 +237,7 @@ export function LoginPage() {
221
237
  )}
222
238
 
223
239
  {/* Email/password form — only in local/dev mode */}
224
- {emailEnabled && (
240
+ {configLoaded && emailEnabled && (
225
241
  <>
226
242
  <form onSubmit={handleSubmit} className="space-y-4">
227
243
  {isRegister && (
@@ -2,6 +2,7 @@ import React, { useEffect, useState, useCallback } from "react";
2
2
  import { pairingApi, setupApi, type PairingToken } from "../api";
3
3
  import { useAppState } from "../store";
4
4
  import { dlog } from "../debug-log";
5
+ import { E2eService } from "../e2e";
5
6
 
6
7
  /** Clipboard copy button with feedback */
7
8
  function CopyButton({ text }: { text: string }) {
@@ -78,6 +79,14 @@ export function OnboardingPage({ onSkip }: { onSkip: () => void }) {
78
79
  const [pairingToken, setPairingToken] = useState<string | null>(null);
79
80
  const [loadingToken, setLoadingToken] = useState(true);
80
81
 
82
+ // E2E password step
83
+ const [e2ePassword, setE2ePassword] = useState("");
84
+ const [e2eConfirm, setE2eConfirm] = useState("");
85
+ const [e2eRemember, setE2eRemember] = useState(true);
86
+ const [e2eReady, setE2eReady] = useState(false); // true after password is set
87
+ const [e2eError, setE2eError] = useState("");
88
+ const [e2eLoading, setE2eLoading] = useState(false);
89
+
81
90
  // Cloud URL — resolved by backend (smart priority), editable by user
82
91
  const [cloudUrl, setCloudUrl] = useState<string>(
83
92
  typeof window !== "undefined" ? window.location.origin : "https://console.botschat.app",
@@ -138,10 +147,32 @@ export function OnboardingPage({ onSkip }: { onSkip: () => void }) {
138
147
  return () => { cancelled = true; };
139
148
  }, []);
140
149
 
150
+ // E2E password validation
151
+ const e2ePasswordValid = e2ePassword.length >= 6 && e2ePassword === e2eConfirm;
152
+
153
+ const handleE2eSubmit = async () => {
154
+ if (!e2ePasswordValid) return;
155
+ if (!state.user?.id) {
156
+ setE2eError("User not loaded yet. Please wait.");
157
+ return;
158
+ }
159
+ setE2eLoading(true);
160
+ setE2eError("");
161
+ try {
162
+ await E2eService.setPassword(e2ePassword, state.user.id, e2eRemember);
163
+ setE2eReady(true);
164
+ } catch (err) {
165
+ setE2eError("Failed to derive encryption key. Please try again.");
166
+ } finally {
167
+ setE2eLoading(false);
168
+ }
169
+ };
170
+
141
171
  const setupCommand = pairingToken
142
172
  ? `openclaw plugins install @botschat/botschat && \\
143
173
  openclaw config set channels.botschat.cloudUrl ${cloudUrl} && \\
144
174
  openclaw config set channels.botschat.pairingToken ${pairingToken} && \\
175
+ openclaw config set channels.botschat.e2ePassword "${e2ePassword}" && \\
145
176
  openclaw config set channels.botschat.enabled true && \\
146
177
  openclaw gateway restart`
147
178
  : "Loading...";
@@ -220,14 +251,130 @@ openclaw gateway restart`
220
251
  </span>
221
252
  </div>
222
253
 
223
- {/* Step 1 */}
254
+ {/* Step 1: E2E Password (mandatory) */}
224
255
  <div className="mb-6">
256
+ <div className="flex items-center gap-2 mb-2">
257
+ <span
258
+ className="inline-flex items-center justify-center w-6 h-6 rounded-full text-tiny font-bold text-white"
259
+ style={{ background: e2eReady ? "var(--accent-green)" : "var(--bg-active)" }}
260
+ >
261
+ {e2eReady ? (
262
+ <svg className="w-3.5 h-3.5" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
263
+ <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
264
+ </svg>
265
+ ) : "1"}
266
+ </span>
267
+ <h3 className="text-body font-bold" style={{ color: "var(--text-primary)" }}>
268
+ Set your E2E encryption password
269
+ </h3>
270
+ </div>
271
+
272
+ {!e2eReady ? (
273
+ <div className="ml-8">
274
+ <p className="text-caption mb-3" style={{ color: "var(--text-secondary)" }}>
275
+ Your messages, prompts, and task results will be <strong>encrypted on this device</strong> before
276
+ they leave — the server only stores ciphertext it cannot read.
277
+ </p>
278
+
279
+ {/* Architecture diagram */}
280
+ <div className="mb-3 rounded-md overflow-hidden" style={{ border: "1px solid var(--border)" }}>
281
+ <img
282
+ src="/architecture.png"
283
+ alt="BotsChat E2E Encryption Architecture"
284
+ className="w-full"
285
+ style={{ display: "block" }}
286
+ />
287
+ </div>
288
+ <p className="text-caption mb-4" style={{ color: "var(--text-muted)" }}>
289
+ Encryption keys are derived locally and never sent to the server.{" "}
290
+ <a
291
+ href="https://botschat.app/#features"
292
+ target="_blank"
293
+ rel="noopener noreferrer"
294
+ className="underline"
295
+ style={{ color: "var(--text-link)" }}
296
+ >
297
+ Learn more
298
+ </a>
299
+ </p>
300
+
301
+ {/* Password inputs */}
302
+ <div className="space-y-2.5">
303
+ <input
304
+ type="password"
305
+ value={e2ePassword}
306
+ onChange={(e) => setE2ePassword(e.target.value)}
307
+ placeholder="E2E encryption password (min 6 chars)"
308
+ className="w-full px-3 py-2 rounded-sm text-caption"
309
+ style={{
310
+ background: "var(--code-bg)",
311
+ border: "1px solid var(--border)",
312
+ color: "var(--text-primary)",
313
+ outline: "none",
314
+ }}
315
+ />
316
+ <input
317
+ type="password"
318
+ value={e2eConfirm}
319
+ onChange={(e) => setE2eConfirm(e.target.value)}
320
+ placeholder="Confirm password"
321
+ className="w-full px-3 py-2 rounded-sm text-caption"
322
+ style={{
323
+ background: "var(--code-bg)",
324
+ border: `1px solid ${e2eConfirm && e2ePassword !== e2eConfirm ? "var(--accent-red, #e53e3e)" : "var(--border)"}`,
325
+ color: "var(--text-primary)",
326
+ outline: "none",
327
+ }}
328
+ />
329
+ {e2eConfirm && e2ePassword !== e2eConfirm && (
330
+ <p className="text-caption" style={{ color: "var(--accent-red, #e53e3e)" }}>
331
+ Passwords do not match.
332
+ </p>
333
+ )}
334
+
335
+ {/* Remember checkbox */}
336
+ <label className="flex items-center gap-2 text-caption" style={{ color: "var(--text-secondary)" }}>
337
+ <input
338
+ type="checkbox"
339
+ checked={e2eRemember}
340
+ onChange={(e) => setE2eRemember(e.target.checked)}
341
+ />
342
+ Remember on this device
343
+ </label>
344
+
345
+ {e2eError && (
346
+ <p className="text-caption" style={{ color: "var(--accent-red, #e53e3e)" }}>{e2eError}</p>
347
+ )}
348
+
349
+ <button
350
+ onClick={handleE2eSubmit}
351
+ disabled={!e2ePasswordValid || e2eLoading}
352
+ className="w-full py-2 font-bold text-caption text-white rounded-sm transition-colors"
353
+ style={{
354
+ background: e2ePasswordValid && !e2eLoading ? "var(--bg-active)" : "var(--bg-hover)",
355
+ cursor: e2ePasswordValid && !e2eLoading ? "pointer" : "not-allowed",
356
+ opacity: e2ePasswordValid && !e2eLoading ? 1 : 0.5,
357
+ }}
358
+ >
359
+ {e2eLoading ? "Deriving key..." : "Set E2E Password & Continue"}
360
+ </button>
361
+ </div>
362
+ </div>
363
+ ) : (
364
+ <p className="text-caption ml-8" style={{ color: "var(--accent-green)" }}>
365
+ E2E encryption is active. Your encryption key has been derived.
366
+ </p>
367
+ )}
368
+ </div>
369
+
370
+ {/* Step 2: Install command (only shown after E2E password set) */}
371
+ <div className="mb-6" style={{ opacity: e2eReady ? 1 : 0.4, pointerEvents: e2eReady ? "auto" : "none" }}>
225
372
  <div className="flex items-center gap-2 mb-2">
226
373
  <span
227
374
  className="inline-flex items-center justify-center w-6 h-6 rounded-full text-tiny font-bold text-white"
228
375
  style={{ background: "var(--bg-active)" }}
229
376
  >
230
- 1
377
+ 2
231
378
  </span>
232
379
  <h3 className="text-body font-bold" style={{ color: "var(--text-primary)" }}>
233
380
  Run this command on your OpenClaw machine
@@ -306,14 +453,14 @@ openclaw gateway restart`
306
453
  </div>
307
454
  </div>
308
455
 
309
- {/* Step 2 */}
310
- <div className="mb-6">
456
+ {/* Step 3: Verify */}
457
+ <div className="mb-6" style={{ opacity: e2eReady ? 1 : 0.4, pointerEvents: e2eReady ? "auto" : "none" }}>
311
458
  <div className="flex items-center gap-2 mb-2">
312
459
  <span
313
460
  className="inline-flex items-center justify-center w-6 h-6 rounded-full text-tiny font-bold text-white"
314
461
  style={{ background: "var(--bg-active)" }}
315
462
  >
316
- 2
463
+ 3
317
464
  </span>
318
465
  <h3 className="text-body font-bold" style={{ color: "var(--text-primary)" }}>
319
466
  Verify connection
@@ -0,0 +1,133 @@
1
+ import { deriveKey, encryptText, decryptText, toBase64, fromBase64 } from "e2e-crypto";
2
+
3
+ const STORAGE_KEY = "botschat_e2e_pwd_cache"; // Stores encrypted password? Or password itself?
4
+ // For MVP, plan says: "Remember on this device" -> store password in localStorage (implicit risk acceptable for user convenience).
5
+ // Actually, storing password in localStorage is common for "Remember Me" if we don't have better key storage.
6
+ // We can store a hash? No, we need the password to derive the key.
7
+ // So we store the password.
8
+
9
+ let currentKey: Uint8Array | null = null;
10
+ let currentPassword: string | null = null;
11
+ const listeners: Set<() => void> = new Set();
12
+
13
+ export const E2eService = {
14
+ /**
15
+ * Subscribe to key state changes. Returns unsubscribe function.
16
+ */
17
+ subscribe(cb: () => void): () => void {
18
+ listeners.add(cb);
19
+ return () => listeners.delete(cb);
20
+ },
21
+
22
+ /**
23
+ * Notify all listeners.
24
+ */
25
+ notify() {
26
+ listeners.forEach((cb) => cb());
27
+ },
28
+
29
+ /**
30
+ * Set the E2E password and derive the key.
31
+ * Optionally persist the password to localStorage.
32
+ */
33
+ async setPassword(password: string, userId: string, remember: boolean): Promise<void> {
34
+ if (!password) {
35
+ currentKey = null;
36
+ currentPassword = null;
37
+ localStorage.removeItem(STORAGE_KEY);
38
+ this.notify();
39
+ return;
40
+ }
41
+
42
+ try {
43
+ currentKey = await deriveKey(password, userId);
44
+ currentPassword = password;
45
+ if (remember) {
46
+ localStorage.setItem(STORAGE_KEY, password);
47
+ } else {
48
+ localStorage.removeItem(STORAGE_KEY);
49
+ }
50
+ this.notify();
51
+ } catch (err) {
52
+ console.error("Failed to derive E2E key:", err);
53
+ throw err;
54
+ }
55
+ },
56
+
57
+ /**
58
+ * Clear the key and password from memory and storage.
59
+ */
60
+ clear(): void {
61
+ currentKey = null;
62
+ currentPassword = null;
63
+ localStorage.removeItem(STORAGE_KEY);
64
+ this.notify();
65
+ },
66
+
67
+ /**
68
+ * Check if we have a key loaded.
69
+ */
70
+ hasKey(): boolean {
71
+ return !!currentKey;
72
+ },
73
+
74
+ /**
75
+ * Check if we have a saved password in storage.
76
+ */
77
+ hasSavedPassword(): boolean {
78
+ return !!localStorage.getItem(STORAGE_KEY);
79
+ },
80
+
81
+ /**
82
+ * Try to load the password from storage and derive key.
83
+ */
84
+ async loadSavedPassword(userId: string): Promise<boolean> {
85
+ const pwd = localStorage.getItem(STORAGE_KEY);
86
+ if (!pwd) return false;
87
+ try {
88
+ await this.setPassword(pwd, userId, true);
89
+ return true;
90
+ } catch {
91
+ return false;
92
+ }
93
+ },
94
+
95
+ /**
96
+ * Encrypt text using the current key.
97
+ * Generates a random messageId (UUID) as contextId/nonce source.
98
+ * Returns { ciphertext: base64, messageId: string }
99
+ */
100
+ async encrypt(text: string): Promise<{ ciphertext: string; messageId: string }> {
101
+ if (!currentKey) throw new Error("E2E key not set");
102
+ const messageId = crypto.randomUUID();
103
+ const encrypted = await encryptText(currentKey, text, messageId);
104
+ return { ciphertext: toBase64(encrypted), messageId };
105
+ },
106
+
107
+ /**
108
+ * Decrypt text (base64) using the current key and messageId (contextId).
109
+ */
110
+ async decrypt(ciphertextBase64: string, messageId: string): Promise<string> {
111
+ if (!currentKey) throw new Error("E2E key not set");
112
+ const ciphertext = fromBase64(ciphertextBase64);
113
+ return decryptText(currentKey, ciphertext, messageId);
114
+ },
115
+
116
+ /**
117
+ * Get the current E2E password (in memory). Returns null if not set.
118
+ */
119
+ getPassword(): string | null {
120
+ return currentPassword;
121
+ },
122
+
123
+ /**
124
+ * Decrypt bytes (base64) -> Uint8Array.
125
+ */
126
+ async decryptBytes(ciphertextBase64: string, messageId: string): Promise<Uint8Array> {
127
+ if (!currentKey) throw new Error("E2E key not set");
128
+ const ciphertext = fromBase64(ciphertextBase64);
129
+ // decryptText returns string; re-encode to bytes
130
+ const plainStr = await decryptText(currentKey, ciphertext, messageId);
131
+ return new TextEncoder().encode(plainStr);
132
+ }
133
+ };
@@ -2,6 +2,9 @@ import React from "react";
2
2
  import ReactDOM from "react-dom/client";
3
3
  import App from "./App";
4
4
  import "./index.css";
5
+ import { initAnalytics } from "./analytics";
6
+
7
+ initAnalytics();
5
8
 
6
9
  ReactDOM.createRoot(document.getElementById("root")!).render(
7
10
  <React.StrictMode>
@@ -14,6 +14,7 @@ export type ChatMessage = {
14
14
  isStreaming?: boolean; // true while streaming is in progress
15
15
  /** Tracks which action blocks have been resolved, keyed by prompt hash */
16
16
  resolvedActions?: Record<string, { value: string; label: string }>;
17
+ isEncryptedLocked?: boolean;
17
18
  };
18
19
 
19
20
  export type ActiveView = "messages" | "automations";
@@ -302,12 +303,12 @@ export function appReducer(state: AppState, action: AppAction): AppState {
302
303
  };
303
304
  }
304
305
  case "SET_OPENCLAW_CONNECTED":
305
- // connection.status carries the global defaultModel from OpenClaw config.
306
- // It never touches sessionModel that's per-session and managed separately.
306
+ // connection.status carries the gateway defaultModel (OpenClaw primary).
307
+ // Prefer user's saved default (from API/settings); only use gateway default when user has not set one.
307
308
  return {
308
309
  ...state,
309
310
  openclawConnected: action.connected,
310
- defaultModel: action.defaultModel ?? state.defaultModel,
311
+ defaultModel: state.defaultModel ?? action.defaultModel ?? null,
311
312
  };
312
313
  case "SET_SESSION_MODEL":
313
314
  return { ...state, sessionModel: action.model };
@@ -1,6 +1,7 @@
1
1
  /** WebSocket client for connecting to the BotsChat ConnectionDO. */
2
2
 
3
3
  import { dlog } from "./debug-log";
4
+ import { E2eService } from "./e2e";
4
5
 
5
6
  export type WSMessage = {
6
7
  type: string;
@@ -44,19 +45,75 @@ export class BotsChatWSClient {
44
45
  this.ws!.send(JSON.stringify({ type: "auth", token: this.opts.token }));
45
46
  };
46
47
 
47
- this.ws.onmessage = (evt) => {
48
+ this.ws.onmessage = async (evt) => {
48
49
  try {
49
50
  const msg = JSON.parse(evt.data) as WSMessage;
51
+
52
+ // Handle E2E Decryption
53
+ if (msg.encrypted && E2eService.hasKey()) {
54
+ try {
55
+ if (msg.type === "agent.text" || msg.type === "agent.media") {
56
+ // Decrypt text/caption
57
+ const text = msg.text as string | undefined;
58
+ const caption = msg.caption as string | undefined;
59
+ const messageId = msg.messageId as string;
60
+
61
+ if (text && messageId) {
62
+ msg.text = await E2eService.decrypt(text, messageId);
63
+ msg.encrypted = false;
64
+ }
65
+ if (caption && messageId) {
66
+ msg.caption = await E2eService.decrypt(caption, messageId);
67
+ msg.encrypted = false;
68
+ }
69
+ } else if (msg.type === "job.update") {
70
+ const summary = msg.summary as string;
71
+ // Job ID is contextId
72
+ const jobId = msg.jobId as string;
73
+ if (summary && jobId) {
74
+ msg.summary = await E2eService.decrypt(summary, jobId);
75
+ msg.encrypted = false;
76
+ }
77
+ }
78
+ } catch (err) {
79
+ dlog.warn("E2E", "Decryption failed", err);
80
+ msg.decryptionError = true;
81
+ }
82
+ }
83
+
84
+ // Handle Task Scan Results (array items)
85
+ if (msg.type === "task.scan.result" && Array.isArray(msg.tasks) && E2eService.hasKey()) {
86
+ for (const t of msg.tasks) {
87
+ if (t.encrypted && t.iv) {
88
+ try {
89
+ if (t.schedule) t.schedule = await E2eService.decrypt(t.schedule, t.iv);
90
+ if (t.instructions) t.instructions = await E2eService.decrypt(t.instructions, t.iv);
91
+ t.encrypted = false;
92
+ } catch (err) {
93
+ dlog.warn("E2E", `Task decryption failed for ${t.cronJobId}`, err);
94
+ t.decryptionError = true;
95
+ }
96
+ }
97
+ }
98
+ }
99
+
50
100
  if (msg.type === "auth.ok") {
51
101
  dlog.info("WS", "Auth OK — connected");
102
+
103
+ // Try to load E2E password
104
+ const userId = msg.userId as string;
105
+ if (userId) {
106
+ await E2eService.loadSavedPassword(userId);
107
+ }
108
+
52
109
  this.backoffMs = 1000;
53
110
  this._connected = true;
54
111
  this.opts.onStatusChange(true);
55
112
  } else {
56
113
  this.opts.onMessage(msg);
57
114
  }
58
- } catch {
59
- dlog.warn("WS", "Failed to parse incoming message", evt.data);
115
+ } catch (err) {
116
+ dlog.warn("WS", "Failed to process incoming message", err);
60
117
  }
61
118
  };
62
119
 
@@ -79,8 +136,23 @@ export class BotsChatWSClient {
79
136
  };
80
137
  }
81
138
 
82
- send(msg: WSMessage): void {
139
+ async send(msg: WSMessage): Promise<void> {
83
140
  if (this.ws?.readyState === WebSocket.OPEN) {
141
+ // E2E Encryption for user messages
142
+ if (msg.type === "user.message" && E2eService.hasKey() && typeof msg.text === "string") {
143
+ try {
144
+ const { ciphertext, messageId } = await E2eService.encrypt(msg.text);
145
+ msg.text = ciphertext;
146
+ msg.messageId = messageId;
147
+ msg.encrypted = true;
148
+ } catch (err) {
149
+ dlog.error("E2E", "Encryption failed", err);
150
+ // Fail? or send as plaintext?
151
+ // Security first: if key exists but encrypt fails, abort.
152
+ return;
153
+ }
154
+ }
155
+
84
156
  this.ws.send(JSON.stringify(msg));
85
157
  } else {
86
158
  dlog.warn("WS", `Cannot send — socket not open (readyState=${this.ws?.readyState})`, msg);
package/scripts/dev.sh CHANGED
@@ -58,16 +58,16 @@ do_start() {
58
58
  }
59
59
 
60
60
  do_sync_plugin() {
61
- local REMOTE="mini.local"
61
+ local REMOTE_USER="mini.local"
62
62
  local REMOTE_DIR="~/Projects/botsChat/packages/plugin"
63
63
 
64
- info "Syncing plugin to $REMOTE…"
64
+ info "Syncing plugin to mini.local…"
65
65
  rsync -avz --exclude node_modules --exclude .git --exclude dist --exclude .wrangler \
66
- packages/plugin/ "$REMOTE:$REMOTE_DIR/"
66
+ packages/plugin/ "$REMOTE_USER:$REMOTE_DIR/"
67
67
  ok "Plugin files synced"
68
68
 
69
69
  info "Building plugin, deploying to extensions, restarting gateway on mini.local…"
70
- ssh "$REMOTE" 'export PATH="/opt/homebrew/bin:$PATH"
70
+ ssh "$REMOTE_USER" 'export PATH="/opt/homebrew/bin:$PATH"
71
71
  cd ~/Projects/botsChat/packages/plugin
72
72
  npm run build
73
73
  EXT_DIR=~/.openclaw/extensions/botschat
@@ -83,7 +83,7 @@ echo "Gateway restarted (PID=$!)"'
83
83
 
84
84
  sleep 4
85
85
  info "Checking connection…"
86
- ssh "$REMOTE" 'tail -5 /tmp/openclaw-gateway.log | grep -i "authenticated\|error\|Task scan"'
86
+ ssh "$REMOTE_USER" 'tail -5 /tmp/openclaw-gateway.log | grep -i "authenticated\|error\|Task scan"'
87
87
  }
88
88
 
89
89
  do_logs() {