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.
- package/README.md +62 -22
- package/migrations/0011_e2e_encryption.sql +35 -0
- package/package.json +4 -2
- package/packages/api/src/do/connection-do.ts +34 -9
- package/packages/api/src/index.ts +29 -7
- package/packages/api/src/routes/auth.ts +4 -1
- package/packages/api/src/routes/setup.ts +2 -0
- package/packages/plugin/dist/src/accounts.d.ts.map +1 -1
- package/packages/plugin/dist/src/accounts.js +1 -0
- package/packages/plugin/dist/src/accounts.js.map +1 -1
- package/packages/plugin/dist/src/channel.d.ts +1 -0
- package/packages/plugin/dist/src/channel.d.ts.map +1 -1
- package/packages/plugin/dist/src/channel.js +142 -6
- package/packages/plugin/dist/src/channel.js.map +1 -1
- package/packages/plugin/dist/src/types.d.ts +16 -0
- package/packages/plugin/dist/src/types.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.d.ts +2 -0
- package/packages/plugin/dist/src/ws-client.d.ts.map +1 -1
- package/packages/plugin/dist/src/ws-client.js +14 -3
- package/packages/plugin/dist/src/ws-client.js.map +1 -1
- package/packages/plugin/package.json +3 -2
- package/packages/web/dist/architecture.png +0 -0
- package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
- package/packages/web/dist/assets/{index-BST9bfvT.css → index-ewBIratI.css} +1 -1
- package/packages/web/dist/index.html +2 -2
- package/packages/web/package.json +1 -0
- package/packages/web/src/App.tsx +46 -8
- package/packages/web/src/analytics.ts +57 -0
- package/packages/web/src/api.ts +4 -0
- package/packages/web/src/components/ConnectionSettings.tsx +3 -1
- package/packages/web/src/components/E2ESettings.tsx +122 -0
- package/packages/web/src/components/IconRail.tsx +1 -12
- package/packages/web/src/components/LoginPage.tsx +19 -3
- package/packages/web/src/components/OnboardingPage.tsx +152 -5
- package/packages/web/src/e2e.ts +133 -0
- package/packages/web/src/main.tsx +3 -0
- package/packages/web/src/store.ts +4 -3
- package/packages/web/src/ws.ts +76 -4
- package/scripts/dev.sh +5 -5
- package/scripts/test-e2e-live.ts +194 -0
- package/scripts/verify-e2e-db.ts +48 -0
- package/scripts/verify-e2e.ts +56 -0
- 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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
306
|
-
//
|
|
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:
|
|
311
|
+
defaultModel: state.defaultModel ?? action.defaultModel ?? null,
|
|
311
312
|
};
|
|
312
313
|
case "SET_SESSION_MODEL":
|
|
313
314
|
return { ...state, sessionModel: action.model };
|
package/packages/web/src/ws.ts
CHANGED
|
@@ -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
|
|
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
|
|
61
|
+
local REMOTE_USER="mini.local"
|
|
62
62
|
local REMOTE_DIR="~/Projects/botsChat/packages/plugin"
|
|
63
63
|
|
|
64
|
-
info "Syncing plugin to
|
|
64
|
+
info "Syncing plugin to mini.local…"
|
|
65
65
|
rsync -avz --exclude node_modules --exclude .git --exclude dist --exclude .wrangler \
|
|
66
|
-
packages/plugin/ "$
|
|
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 "$
|
|
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 "$
|
|
86
|
+
ssh "$REMOTE_USER" 'tail -5 /tmp/openclaw-gateway.log | grep -i "authenticated\|error\|Task scan"'
|
|
87
87
|
}
|
|
88
88
|
|
|
89
89
|
do_logs() {
|