botschat 0.1.4 → 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 +64 -24
- package/migrations/0011_e2e_encryption.sql +35 -0
- package/package.json +7 -2
- package/packages/api/package.json +2 -1
- package/packages/api/src/do/connection-do.ts +162 -42
- package/packages/api/src/index.ts +132 -13
- package/packages/api/src/routes/auth.ts +127 -30
- package/packages/api/src/routes/pairing.ts +14 -1
- package/packages/api/src/routes/setup.ts +72 -24
- package/packages/api/src/routes/upload.ts +12 -8
- package/packages/api/src/utils/auth.ts +212 -43
- package/packages/api/src/utils/id.ts +30 -14
- package/packages/api/src/utils/rate-limit.ts +73 -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 +151 -9
- 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 +4 -3
- package/packages/web/dist/architecture.png +0 -0
- package/packages/web/dist/assets/index-BoNQoJjQ.js +1497 -0
- package/packages/web/dist/assets/{index-DuGeoFJT.css → index-ewBIratI.css} +1 -1
- package/packages/web/dist/botschat-icon.svg +4 -0
- package/packages/web/dist/index.html +23 -3
- package/packages/web/dist/manifest.json +24 -0
- package/packages/web/dist/sw.js +40 -0
- package/packages/web/index.html +21 -1
- package/packages/web/package.json +1 -0
- package/packages/web/src/App.tsx +286 -103
- package/packages/web/src/analytics.ts +57 -0
- package/packages/web/src/api.ts +67 -3
- package/packages/web/src/components/ChatWindow.tsx +11 -11
- package/packages/web/src/components/ConnectionSettings.tsx +477 -0
- package/packages/web/src/components/CronDetail.tsx +475 -235
- package/packages/web/src/components/CronSidebar.tsx +1 -1
- package/packages/web/src/components/DebugLogPanel.tsx +116 -3
- package/packages/web/src/components/E2ESettings.tsx +122 -0
- package/packages/web/src/components/IconRail.tsx +56 -27
- package/packages/web/src/components/JobList.tsx +2 -6
- package/packages/web/src/components/LoginPage.tsx +143 -104
- package/packages/web/src/components/MobileLayout.tsx +480 -0
- package/packages/web/src/components/OnboardingPage.tsx +159 -21
- package/packages/web/src/components/ResizeHandle.tsx +34 -0
- package/packages/web/src/components/Sidebar.tsx +1 -1
- package/packages/web/src/components/TaskBar.tsx +2 -2
- package/packages/web/src/components/ThreadPanel.tsx +2 -5
- package/packages/web/src/e2e.ts +133 -0
- package/packages/web/src/hooks/useIsMobile.ts +27 -0
- package/packages/web/src/index.css +59 -0
- package/packages/web/src/main.tsx +12 -0
- package/packages/web/src/store.ts +16 -8
- package/packages/web/src/ws.ts +78 -4
- package/scripts/dev.sh +16 -16
- package/scripts/test-e2e-live.ts +194 -0
- package/scripts/verify-e2e-db.ts +48 -0
- package/scripts/verify-e2e.ts +56 -0
- package/wrangler.toml +3 -1
- package/packages/web/dist/assets/index-DyzTR_Y4.js +0 -847
package/README.md
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
# BotsChat
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/botschat)
|
|
4
|
-
[](https://www.npmjs.com/package/@botschat/botschat)
|
|
5
5
|
[](LICENSE)
|
|
6
6
|
|
|
7
|
-
A self-hosted chat interface for [OpenClaw](https://github.com/openclaw/openclaw) AI agents.
|
|
7
|
+
A self-hosted, **end-to-end encrypted** chat interface for [OpenClaw](https://github.com/openclaw/openclaw) AI agents.
|
|
8
8
|
|
|
9
|
-
BotsChat gives you a modern, Slack-like web UI to interact with your OpenClaw agents — organize conversations into **Channels**, schedule **Background Tasks**, and monitor **Job** executions.
|
|
9
|
+
BotsChat gives you a modern, Slack-like web UI to interact with your OpenClaw agents — organize conversations into **Channels**, schedule **Background Tasks**, and monitor **Job** executions. With **E2E encryption**, your chat messages, cron prompts, and job summaries are encrypted on your device before they ever leave — the server only sees ciphertext it cannot decrypt. Your API keys and data never leave your machine.
|
|
10
10
|
|
|
11
11
|
## Key Features
|
|
12
12
|
|
|
@@ -34,6 +34,15 @@ Schedule **cron-style background tasks** that run your agents on autopilot. Each
|
|
|
34
34
|
|
|
35
35
|

|
|
36
36
|
|
|
37
|
+
### End-to-End Encryption
|
|
38
|
+
|
|
39
|
+
BotsChat supports **optional E2E encryption** so the server never sees your content in plaintext:
|
|
40
|
+
|
|
41
|
+
- **What's encrypted**: Chat messages, cron task prompts, and job execution summaries — all encrypted with AES-256-CTR before leaving your browser or plugin.
|
|
42
|
+
- **Zero-knowledge server**: The BotsChat cloud/server stores only ciphertext and cannot decrypt your data. No keys, no salts stored server-side.
|
|
43
|
+
- **How it works**: You set an E2E password in both the web UI and the OpenClaw plugin. Both sides derive the same encryption key using `PBKDF2(password, userId)`. Messages are encrypted/decrypted locally — the server just relays and stores opaque bytes.
|
|
44
|
+
- **Zero overhead**: AES-CTR produces ciphertext the same size as plaintext — no bloat, no padding.
|
|
45
|
+
|
|
37
46
|
### Built-in Debug Log
|
|
38
47
|
|
|
39
48
|
A collapsible **Debug Log** panel at the bottom of the UI gives you real-time visibility into what's happening under the hood — WebSocket events, cron task loading, agent scan results, and more. Filter by log level (ALL, WS, WST, API, INF, WRN, ERR) to quickly diagnose issues without leaving the chat interface.
|
|
@@ -46,7 +55,9 @@ A collapsible **Debug Log** panel at the bottom of the UI gives you real-time vi
|
|
|
46
55
|
|
|
47
56
|

|
|
48
57
|
|
|
49
|
-
OpenClaw runs your agents locally (with your API keys, data, and configs). The BotsChat plugin establishes an **outbound WebSocket** to the BotsChat server — no port forwarding, no tunnels. Your API keys and data never leave your machine
|
|
58
|
+
OpenClaw runs your agents locally (with your API keys, data, and configs). The BotsChat plugin establishes an **outbound WebSocket** to the BotsChat server — no port forwarding, no tunnels. Your API keys and data never leave your machine.
|
|
59
|
+
|
|
60
|
+
When **E2E encryption** is enabled, messages are encrypted on the sender's device (browser or plugin) before transmission. The BotsChat server (ConnectionDO) only relays and stores opaque ciphertext — it has no access to keys and cannot read your content. Encryption keys are derived locally from your password and never sent over the network.
|
|
50
61
|
|
|
51
62
|
You can run BotsChat locally on the same machine, or deploy it to Cloudflare for remote access (e.g. from your phone).
|
|
52
63
|
|
|
@@ -71,36 +82,42 @@ BotsChat introduces a few UI-level concepts that map to OpenClaw primitives:
|
|
|
71
82
|
|
|
72
83
|
### Prerequisites
|
|
73
84
|
|
|
74
|
-
- [Node.js](https://nodejs.org/) 22+
|
|
75
|
-
- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/)
|
|
76
85
|
- An [OpenClaw](https://github.com/openclaw/openclaw) instance
|
|
86
|
+
- For self-hosting (Option B or C): [Node.js](https://nodejs.org/) 22+, [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/install-and-update/)
|
|
77
87
|
|
|
78
|
-
###
|
|
88
|
+
### Choose Your Deployment
|
|
79
89
|
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
90
|
+
BotsChat is **100% open source** — the [same code](https://github.com/botschat-app/botsChat) runs whether you use our hosted console, run it locally, or deploy to your own Cloudflare. The only difference is *where* the server runs; your API keys and data always stay on your machine.
|
|
91
|
+
|
|
92
|
+
| Mode | Best for | Clone repo? |
|
|
93
|
+
|------|----------|-------------|
|
|
94
|
+
| **A. Hosted Console** | Zero setup, start in minutes | No |
|
|
95
|
+
| **B. Run Locally** | Development, no cloud account | Yes |
|
|
96
|
+
| **C. Deploy to Cloudflare** | Remote access (e.g. from phone) | Yes |
|
|
85
97
|
|
|
86
|
-
|
|
98
|
+
Pick one below and follow its steps, then continue to [Install the OpenClaw Plugin](#install-the-openclaw-plugin).
|
|
99
|
+
|
|
100
|
+
---
|
|
87
101
|
|
|
88
|
-
|
|
102
|
+
#### Option A: Hosted Console (Recommended)
|
|
89
103
|
|
|
90
|
-
|
|
104
|
+
We run the same open-source stack at **[console.botschat.app](https://console.botschat.app)**. No clone, no deploy: open the link → sign up → create a pairing token → connect OpenClaw.
|
|
91
105
|
|
|
92
|
-
|
|
106
|
+
Your API keys and data still stay on your machine; the hosted console only relays chat messages via WebSocket. Enable **E2E encryption** for complete privacy — the hosted console cannot decrypt your content.
|
|
93
107
|
|
|
94
|
-
|
|
108
|
+
→ Then go to [Install the OpenClaw Plugin](#install-the-openclaw-plugin).
|
|
95
109
|
|
|
96
|
-
|
|
110
|
+
---
|
|
97
111
|
|
|
98
112
|
#### Option B: Run Locally
|
|
99
113
|
|
|
100
|
-
Wrangler uses [Miniflare](https://miniflare.dev)
|
|
114
|
+
Clone, install, and run the server on your machine. Wrangler uses [Miniflare](https://miniflare.dev), so D1, R2, and Durable Objects all run locally — **no Cloudflare account needed**.
|
|
101
115
|
|
|
102
116
|
```bash
|
|
103
|
-
|
|
117
|
+
git clone https://github.com/botschat-app/botsChat.git
|
|
118
|
+
cd botsChat
|
|
119
|
+
npm install
|
|
120
|
+
# One-command startup: build web → migrate D1 → start on 0.0.0.0:8787
|
|
104
121
|
./scripts/dev.sh
|
|
105
122
|
```
|
|
106
123
|
|
|
@@ -124,11 +141,19 @@ Other dev commands:
|
|
|
124
141
|
./scripts/dev.sh logs # Tail remote gateway logs
|
|
125
142
|
```
|
|
126
143
|
|
|
144
|
+
→ Then go to [Install the OpenClaw Plugin](#install-the-openclaw-plugin).
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
127
148
|
#### Option C: Deploy to Cloudflare
|
|
128
149
|
|
|
129
|
-
For remote access (e.g. chatting
|
|
150
|
+
For remote access (e.g. chatting from your phone), deploy the same code to Cloudflare Workers. The free tier is enough for personal use.
|
|
130
151
|
|
|
131
152
|
```bash
|
|
153
|
+
git clone https://github.com/botschat-app/botsChat.git
|
|
154
|
+
cd botsChat
|
|
155
|
+
npm install
|
|
156
|
+
|
|
132
157
|
# Create Cloudflare resources
|
|
133
158
|
wrangler d1 create botschat-db # Copy the database_id into wrangler.toml
|
|
134
159
|
wrangler r2 bucket create botschat-media
|
|
@@ -147,14 +172,18 @@ wrangler secret put JWT_SECRET # Set a production JWT secret
|
|
|
147
172
|
| D1 | Database (users, channels, tasks) | 5M reads/day, 100K writes/day |
|
|
148
173
|
| R2 | Media storage | 10GB, no egress fees |
|
|
149
174
|
|
|
150
|
-
|
|
175
|
+
→ Then go to [Install the OpenClaw Plugin](#install-the-openclaw-plugin).
|
|
176
|
+
|
|
177
|
+
---
|
|
178
|
+
|
|
179
|
+
### Install the OpenClaw Plugin
|
|
151
180
|
|
|
152
181
|
After the BotsChat server is running, connect your OpenClaw instance to it.
|
|
153
182
|
|
|
154
183
|
**1. Install the plugin**
|
|
155
184
|
|
|
156
185
|
```bash
|
|
157
|
-
openclaw plugins install @botschat/
|
|
186
|
+
openclaw plugins install @botschat/botschat
|
|
158
187
|
```
|
|
159
188
|
|
|
160
189
|
**2. Create a pairing token**
|
|
@@ -172,6 +201,14 @@ openclaw config set channels.botschat.pairingToken <YOUR_PAIRING_TOKEN>
|
|
|
172
201
|
openclaw config set channels.botschat.enabled true
|
|
173
202
|
```
|
|
174
203
|
|
|
204
|
+
**3b. (Optional) Enable E2E encryption**
|
|
205
|
+
|
|
206
|
+
Set the same password you'll use in the BotsChat web UI:
|
|
207
|
+
|
|
208
|
+
```bash
|
|
209
|
+
openclaw config set channels.botschat.e2ePassword "your-secret-e2e-password"
|
|
210
|
+
```
|
|
211
|
+
|
|
175
212
|
This writes the following to your `~/.openclaw/openclaw.json`:
|
|
176
213
|
|
|
177
214
|
```json
|
|
@@ -180,7 +217,8 @@ This writes the following to your `~/.openclaw/openclaw.json`:
|
|
|
180
217
|
"botschat": {
|
|
181
218
|
"enabled": true,
|
|
182
219
|
"cloudUrl": "http://localhost:8787",
|
|
183
|
-
"pairingToken": "bc_pat_xxxxxxxxxxxxxxxx"
|
|
220
|
+
"pairingToken": "bc_pat_xxxxxxxxxxxxxxxx",
|
|
221
|
+
"e2ePassword": "your-secret-e2e-password"
|
|
184
222
|
}
|
|
185
223
|
}
|
|
186
224
|
}
|
|
@@ -207,6 +245,7 @@ Open the BotsChat web UI in your browser, sign in, and start chatting with your
|
|
|
207
245
|
2. This WebSocket stays connected (with automatic reconnection if it drops).
|
|
208
246
|
3. When you type a message in the web UI, it travels: **Browser → ConnectionDO → WebSocket → OpenClaw → Agent → response back through the same path**.
|
|
209
247
|
4. Your API keys, agent configs, and data never leave your machine — only chat messages travel through the relay.
|
|
248
|
+
5. With **E2E encryption** enabled, messages are encrypted **before** step 3 and decrypted **after** — the ConnectionDO and database only ever see ciphertext.
|
|
210
249
|
|
|
211
250
|
## Plugin Reference
|
|
212
251
|
|
|
@@ -219,6 +258,7 @@ All config lives under `channels.botschat` in your `openclaw.json`:
|
|
|
219
258
|
| `enabled` | boolean | no | Enable/disable the channel (default: true) |
|
|
220
259
|
| `cloudUrl` | string | yes | BotsChat server URL (e.g. `http://localhost:8787`) |
|
|
221
260
|
| `pairingToken` | string | yes | Your pairing token from the BotsChat dashboard |
|
|
261
|
+
| `e2ePassword` | string | no | E2E encryption password (must match the web UI) |
|
|
222
262
|
| `name` | string | no | Display name for this connection |
|
|
223
263
|
|
|
224
264
|
### Message Protocol
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
-- E2E Encryption: rebuild messages and jobs tables with BLOB columns + encrypted flag.
|
|
2
|
+
-- WARNING: This migration drops all existing data in messages and jobs tables.
|
|
3
|
+
|
|
4
|
+
DROP TABLE IF EXISTS messages;
|
|
5
|
+
CREATE TABLE messages (
|
|
6
|
+
id TEXT PRIMARY KEY,
|
|
7
|
+
user_id TEXT NOT NULL,
|
|
8
|
+
session_key TEXT NOT NULL,
|
|
9
|
+
thread_id TEXT,
|
|
10
|
+
sender TEXT NOT NULL CHECK (sender IN ('user', 'agent')),
|
|
11
|
+
text BLOB,
|
|
12
|
+
media_url TEXT,
|
|
13
|
+
a2ui BLOB,
|
|
14
|
+
encrypted INTEGER NOT NULL DEFAULT 0,
|
|
15
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
16
|
+
);
|
|
17
|
+
CREATE INDEX IF NOT EXISTS idx_messages_session ON messages(session_key, created_at);
|
|
18
|
+
CREATE INDEX IF NOT EXISTS idx_messages_thread ON messages(thread_id, created_at);
|
|
19
|
+
|
|
20
|
+
DROP TABLE IF EXISTS jobs;
|
|
21
|
+
CREATE TABLE jobs (
|
|
22
|
+
id TEXT PRIMARY KEY,
|
|
23
|
+
task_id TEXT NOT NULL,
|
|
24
|
+
user_id TEXT NOT NULL,
|
|
25
|
+
session_key TEXT NOT NULL,
|
|
26
|
+
status TEXT NOT NULL CHECK (status IN ('running', 'ok', 'error', 'skipped')),
|
|
27
|
+
started_at INTEGER NOT NULL DEFAULT (unixepoch()),
|
|
28
|
+
finished_at INTEGER,
|
|
29
|
+
duration_ms INTEGER,
|
|
30
|
+
summary BLOB,
|
|
31
|
+
encrypted INTEGER NOT NULL DEFAULT 0,
|
|
32
|
+
created_at INTEGER NOT NULL DEFAULT (unixepoch())
|
|
33
|
+
);
|
|
34
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_task ON jobs(task_id, started_at DESC);
|
|
35
|
+
CREATE INDEX IF NOT EXISTS idx_jobs_session ON jobs(session_key);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "botschat",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.7",
|
|
4
4
|
"description": "A self-hosted chat interface for OpenClaw AI agents",
|
|
5
5
|
"workspaces": [
|
|
6
6
|
"packages/*"
|
|
@@ -15,7 +15,9 @@
|
|
|
15
15
|
"db:migrate": "wrangler d1 migrations apply botschat-db --local",
|
|
16
16
|
"db:migrate:remote": "wrangler d1 migrations apply botschat-db --remote",
|
|
17
17
|
"typecheck": "tsc --noEmit",
|
|
18
|
-
"build:plugin": "npm run build -w packages/plugin"
|
|
18
|
+
"build:plugin": "npm run build -w packages/plugin",
|
|
19
|
+
"test:e2e": "npx tsx scripts/verify-e2e.ts && npx tsx packages/e2e-crypto/e2e-crypto.test.ts",
|
|
20
|
+
"test:e2e-db": "npx tsx scripts/verify-e2e-db.ts"
|
|
19
21
|
},
|
|
20
22
|
"files": [
|
|
21
23
|
"packages/api/src/",
|
|
@@ -49,5 +51,8 @@
|
|
|
49
51
|
"devDependencies": {
|
|
50
52
|
"typescript": "^5.7.0",
|
|
51
53
|
"wrangler": "^3.100.0"
|
|
54
|
+
},
|
|
55
|
+
"dependencies": {
|
|
56
|
+
"react-resizable-panels": "^4.6.2"
|
|
52
57
|
}
|
|
53
58
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import type { Env } from "../env.js";
|
|
2
|
+
import { verifyToken, getJwtSecret } from "../utils/auth.js";
|
|
3
|
+
import { generateId as generateIdUtil } from "../utils/id.js";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* ConnectionDO — one Durable Object instance per BotsChat user.
|
|
@@ -177,7 +179,10 @@ export class ConnectionDO implements DurableObject {
|
|
|
177
179
|
|
|
178
180
|
if (isValid) {
|
|
179
181
|
ws.serializeAttachment({ ...attachment, authenticated: true });
|
|
180
|
-
|
|
182
|
+
// Include userId so the plugin can derive the E2E key
|
|
183
|
+
const userId = await this.state.storage.get<string>("userId");
|
|
184
|
+
console.log(`[DO] auth.ok → userId=${userId}`);
|
|
185
|
+
ws.send(JSON.stringify({ type: "auth.ok", userId }));
|
|
181
186
|
// Store gateway default model from plugin auth
|
|
182
187
|
if (msg.model) {
|
|
183
188
|
this.defaultModel = msg.model as string;
|
|
@@ -226,12 +231,14 @@ export class ConnectionDO implements DurableObject {
|
|
|
226
231
|
}
|
|
227
232
|
|
|
228
233
|
await this.persistMessage({
|
|
234
|
+
id: msg.messageId as string | undefined,
|
|
229
235
|
sender: "agent",
|
|
230
236
|
sessionKey: msg.sessionKey as string,
|
|
231
237
|
threadId: (msg.threadId ?? msg.replyToId) as string | undefined,
|
|
232
238
|
text: (msg.text ?? msg.caption ?? "") as string,
|
|
233
239
|
mediaUrl: persistedMediaUrl,
|
|
234
240
|
a2ui: msg.jsonl as string | undefined,
|
|
241
|
+
encrypted: msg.encrypted ? 1 : 0,
|
|
235
242
|
});
|
|
236
243
|
}
|
|
237
244
|
|
|
@@ -268,6 +275,15 @@ export class ConnectionDO implements DurableObject {
|
|
|
268
275
|
);
|
|
269
276
|
}
|
|
270
277
|
|
|
278
|
+
// Plugin applied BotsChat default model to OpenClaw config — update and broadcast
|
|
279
|
+
if (msg.type === "defaultModel.updated" && typeof msg.model === "string") {
|
|
280
|
+
this.defaultModel = msg.model;
|
|
281
|
+
await this.state.storage.put("defaultModel", this.defaultModel);
|
|
282
|
+
this.broadcastToBrowsers(
|
|
283
|
+
JSON.stringify({ type: "connection.status", openclawConnected: true, defaultModel: this.defaultModel, models: this.cachedModels }),
|
|
284
|
+
);
|
|
285
|
+
}
|
|
286
|
+
|
|
271
287
|
// Handle job updates from plugin — persist and forward to browsers
|
|
272
288
|
if (msg.type === "job.update") {
|
|
273
289
|
await this.handleJobUpdate(msg);
|
|
@@ -283,12 +299,35 @@ export class ConnectionDO implements DurableObject {
|
|
|
283
299
|
): Promise<void> {
|
|
284
300
|
const attachment = ws.deserializeAttachment() as { authenticated: boolean; tag: string } | null;
|
|
285
301
|
|
|
286
|
-
// Handle browser auth
|
|
302
|
+
// Handle browser auth — verify JWT token
|
|
287
303
|
if (msg.type === "auth") {
|
|
288
|
-
|
|
289
|
-
|
|
304
|
+
const token = msg.token as string | undefined;
|
|
305
|
+
if (!token) {
|
|
306
|
+
ws.send(JSON.stringify({ type: "auth.fail", reason: "Missing token" }));
|
|
307
|
+
ws.close(4001, "Missing auth token");
|
|
308
|
+
return;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const secret = getJwtSecret(this.env);
|
|
312
|
+
const payload = await verifyToken(token, secret);
|
|
313
|
+
if (!payload) {
|
|
314
|
+
ws.send(JSON.stringify({ type: "auth.fail", reason: "Invalid or expired token" }));
|
|
315
|
+
ws.close(4001, "Authentication failed");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Verify the token's userId matches this DO's userId
|
|
320
|
+
const doUserId = await this.state.storage.get<string>("userId");
|
|
321
|
+
if (doUserId && payload.sub !== doUserId) {
|
|
322
|
+
ws.send(JSON.stringify({ type: "auth.fail", reason: "User mismatch" }));
|
|
323
|
+
ws.close(4001, "User mismatch");
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
|
|
290
327
|
ws.serializeAttachment({ ...attachment, authenticated: true });
|
|
291
|
-
|
|
328
|
+
// Include userId so the browser can derive the E2E key
|
|
329
|
+
const doUserId2 = doUserId ?? payload.sub;
|
|
330
|
+
ws.send(JSON.stringify({ type: "auth.ok", userId: doUserId2 }));
|
|
292
331
|
|
|
293
332
|
// Send current OpenClaw connection status + cached models
|
|
294
333
|
await this.ensureCachedModels();
|
|
@@ -323,6 +362,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
323
362
|
sessionKey: msg.sessionKey as string,
|
|
324
363
|
text: (msg.text ?? "") as string,
|
|
325
364
|
mediaUrl: msg.mediaUrl as string | undefined,
|
|
365
|
+
encrypted: msg.encrypted ? 1 : 0,
|
|
326
366
|
});
|
|
327
367
|
}
|
|
328
368
|
|
|
@@ -377,6 +417,8 @@ export class ConnectionDO implements DurableObject {
|
|
|
377
417
|
instructions: (t.instructions as string) ?? "",
|
|
378
418
|
model: (t.model as string) ?? "",
|
|
379
419
|
enabled: t.enabled as boolean,
|
|
420
|
+
encrypted: (t.encrypted as boolean) ?? false,
|
|
421
|
+
iv: (t.iv as string) ?? undefined,
|
|
380
422
|
}));
|
|
381
423
|
return Response.json({ tasks: result });
|
|
382
424
|
} catch (err) {
|
|
@@ -472,6 +514,37 @@ export class ConnectionDO implements DurableObject {
|
|
|
472
514
|
|
|
473
515
|
// ---- Media caching ----
|
|
474
516
|
|
|
517
|
+
// ---- SSRF protection ----
|
|
518
|
+
|
|
519
|
+
/** Check if a URL is safe to fetch (not pointing to private/internal networks). */
|
|
520
|
+
private isUrlSafeToFetch(urlStr: string): boolean {
|
|
521
|
+
try {
|
|
522
|
+
const parsed = new URL(urlStr);
|
|
523
|
+
// Only allow https (block http, ftp, file, etc.)
|
|
524
|
+
if (parsed.protocol !== "https:") return false;
|
|
525
|
+
|
|
526
|
+
const hostname = parsed.hostname;
|
|
527
|
+
// Block private/reserved IP ranges and localhost
|
|
528
|
+
if (
|
|
529
|
+
hostname === "localhost" ||
|
|
530
|
+
hostname === "127.0.0.1" ||
|
|
531
|
+
hostname === "[::1]" ||
|
|
532
|
+
hostname.endsWith(".local") ||
|
|
533
|
+
/^10\./.test(hostname) ||
|
|
534
|
+
/^172\.(1[6-9]|2\d|3[01])\./.test(hostname) ||
|
|
535
|
+
/^192\.168\./.test(hostname) ||
|
|
536
|
+
/^169\.254\./.test(hostname) || // link-local
|
|
537
|
+
/^0\./.test(hostname) ||
|
|
538
|
+
hostname === "[::ffff:127.0.0.1]"
|
|
539
|
+
) {
|
|
540
|
+
return false;
|
|
541
|
+
}
|
|
542
|
+
return true;
|
|
543
|
+
} catch {
|
|
544
|
+
return false;
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
|
|
475
548
|
/**
|
|
476
549
|
* Download an external image and cache it in R2. Returns the local
|
|
477
550
|
* API URL (e.g. /api/media/...) or null if caching fails.
|
|
@@ -483,17 +556,28 @@ export class ConnectionDO implements DurableObject {
|
|
|
483
556
|
// Also skip URLs that point back to our own media endpoint (absolute form)
|
|
484
557
|
if (/\/api\/media\//.test(url)) return null;
|
|
485
558
|
|
|
559
|
+
// SSRF protection: only allow HTTPS URLs to public hosts
|
|
560
|
+
if (!this.isUrlSafeToFetch(url)) {
|
|
561
|
+
console.warn(`[DO] cacheExternalMedia: blocked unsafe URL ${url.slice(0, 120)}`);
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
|
|
486
565
|
console.log(`[DO] cacheExternalMedia: attempting to cache ${url.slice(0, 120)}`);
|
|
487
566
|
|
|
567
|
+
const MAX_MEDIA_SIZE = 20 * 1024 * 1024; // 20 MB max
|
|
568
|
+
|
|
488
569
|
try {
|
|
489
570
|
const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
|
|
490
571
|
|
|
491
572
|
// Download the external image — use arrayBuffer to avoid stream issues
|
|
492
573
|
const controller = new AbortController();
|
|
493
|
-
const timeoutId = setTimeout(() => controller.abort(),
|
|
574
|
+
const timeoutId = setTimeout(() => controller.abort(), 15_000); // 15s timeout
|
|
494
575
|
let response: Response;
|
|
495
576
|
try {
|
|
496
|
-
response = await fetch(url, {
|
|
577
|
+
response = await fetch(url, {
|
|
578
|
+
signal: controller.signal,
|
|
579
|
+
redirect: "follow", // follow redirects, but URL was already validated
|
|
580
|
+
});
|
|
497
581
|
} finally {
|
|
498
582
|
clearTimeout(timeoutId);
|
|
499
583
|
}
|
|
@@ -506,8 +590,21 @@ export class ConnectionDO implements DurableObject {
|
|
|
506
590
|
const contentType = response.headers.get("Content-Type") ?? "image/png";
|
|
507
591
|
// Validate that the response is actually an image
|
|
508
592
|
if (!contentType.startsWith("image/")) {
|
|
509
|
-
console.warn(`[DO] cacheExternalMedia:
|
|
510
|
-
|
|
593
|
+
console.warn(`[DO] cacheExternalMedia: non-image Content-Type "${contentType}", skipping ${url.slice(0, 120)}`);
|
|
594
|
+
return null;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
// Reject SVG (can contain scripts — XSS vector)
|
|
598
|
+
if (contentType.includes("svg")) {
|
|
599
|
+
console.warn(`[DO] cacheExternalMedia: blocked SVG content from ${url.slice(0, 120)}`);
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Check Content-Length header early if available
|
|
604
|
+
const contentLength = parseInt(response.headers.get("Content-Length") ?? "0", 10);
|
|
605
|
+
if (contentLength > MAX_MEDIA_SIZE) {
|
|
606
|
+
console.warn(`[DO] cacheExternalMedia: Content-Length ${contentLength} exceeds limit for ${url.slice(0, 120)}`);
|
|
607
|
+
return null;
|
|
511
608
|
}
|
|
512
609
|
|
|
513
610
|
// Read the body as ArrayBuffer for maximum compatibility with R2
|
|
@@ -516,14 +613,17 @@ export class ConnectionDO implements DurableObject {
|
|
|
516
613
|
console.warn(`[DO] cacheExternalMedia: empty body for ${url.slice(0, 120)}`);
|
|
517
614
|
return null;
|
|
518
615
|
}
|
|
616
|
+
if (body.byteLength > MAX_MEDIA_SIZE) {
|
|
617
|
+
console.warn(`[DO] cacheExternalMedia: body size ${body.byteLength} exceeds limit for ${url.slice(0, 120)}`);
|
|
618
|
+
return null;
|
|
619
|
+
}
|
|
519
620
|
|
|
520
|
-
// Determine extension from Content-Type
|
|
621
|
+
// Determine extension from Content-Type (no SVG)
|
|
521
622
|
const extMap: Record<string, string> = {
|
|
522
623
|
"image/png": "png",
|
|
523
624
|
"image/jpeg": "jpg",
|
|
524
625
|
"image/gif": "gif",
|
|
525
626
|
"image/webp": "webp",
|
|
526
|
-
"image/svg+xml": "svg",
|
|
527
627
|
};
|
|
528
628
|
const ext = extMap[contentType] ?? "png";
|
|
529
629
|
const key = `media/${userId}/${Date.now()}-${crypto.randomUUID().slice(0, 8)}.${ext}`;
|
|
@@ -552,10 +652,12 @@ export class ConnectionDO implements DurableObject {
|
|
|
552
652
|
text: string;
|
|
553
653
|
mediaUrl?: string;
|
|
554
654
|
a2ui?: string;
|
|
655
|
+
encrypted?: number;
|
|
555
656
|
}): Promise<void> {
|
|
556
657
|
try {
|
|
557
658
|
const userId = (await this.state.storage.get<string>("userId")) ?? "unknown";
|
|
558
659
|
const id = opts.id ?? crypto.randomUUID();
|
|
660
|
+
const encrypted = opts.encrypted ?? 0;
|
|
559
661
|
|
|
560
662
|
// Extract threadId from sessionKey pattern: ....:thread:{threadId}
|
|
561
663
|
let threadId = opts.threadId;
|
|
@@ -565,10 +667,10 @@ export class ConnectionDO implements DurableObject {
|
|
|
565
667
|
}
|
|
566
668
|
|
|
567
669
|
await this.env.DB.prepare(
|
|
568
|
-
`INSERT INTO messages (id, user_id, session_key, thread_id, sender, text, media_url, a2ui)
|
|
569
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
670
|
+
`INSERT INTO messages (id, user_id, session_key, thread_id, sender, text, media_url, a2ui, encrypted)
|
|
671
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
570
672
|
)
|
|
571
|
-
.bind(id, userId, opts.sessionKey, threadId ?? null, opts.sender, opts.text, opts.mediaUrl ?? null, opts.a2ui ?? null)
|
|
673
|
+
.bind(id, userId, opts.sessionKey, threadId ?? null, opts.sender, opts.text, opts.mediaUrl ?? null, opts.a2ui ?? null, encrypted)
|
|
572
674
|
.run();
|
|
573
675
|
} catch (err) {
|
|
574
676
|
console.error("Failed to persist message:", err);
|
|
@@ -590,7 +692,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
590
692
|
if (threadId) {
|
|
591
693
|
// Load thread messages
|
|
592
694
|
result = await this.env.DB.prepare(
|
|
593
|
-
`SELECT id, session_key, thread_id, sender, text, media_url, a2ui, created_at
|
|
695
|
+
`SELECT id, session_key, thread_id, sender, text, media_url, a2ui, encrypted, created_at
|
|
594
696
|
FROM messages
|
|
595
697
|
WHERE session_key = ? AND thread_id = ?
|
|
596
698
|
ORDER BY created_at ASC
|
|
@@ -601,7 +703,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
601
703
|
} else {
|
|
602
704
|
// Load main session messages (exclude thread messages from the main list)
|
|
603
705
|
result = await this.env.DB.prepare(
|
|
604
|
-
`SELECT id, session_key, thread_id, sender, text, media_url, a2ui, created_at
|
|
706
|
+
`SELECT id, session_key, thread_id, sender, text, media_url, a2ui, encrypted, created_at
|
|
605
707
|
FROM messages
|
|
606
708
|
WHERE session_key = ? AND thread_id IS NULL
|
|
607
709
|
ORDER BY created_at ASC
|
|
@@ -639,6 +741,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
639
741
|
mediaUrl: row.media_url ?? undefined,
|
|
640
742
|
a2ui: row.a2ui ?? undefined,
|
|
641
743
|
threadId: row.thread_id ?? undefined,
|
|
744
|
+
encrypted: row.encrypted ?? 0,
|
|
642
745
|
}));
|
|
643
746
|
|
|
644
747
|
return Response.json({ messages, replyCounts });
|
|
@@ -853,14 +956,9 @@ export class ConnectionDO implements DurableObject {
|
|
|
853
956
|
return channelId;
|
|
854
957
|
}
|
|
855
958
|
|
|
856
|
-
/** Generate a short random ID (URL-safe). */
|
|
959
|
+
/** Generate a short random ID (URL-safe) using CSPRNG (bias-free). */
|
|
857
960
|
private generateId(prefix = ""): string {
|
|
858
|
-
|
|
859
|
-
let id = prefix;
|
|
860
|
-
for (let i = 0; i < 16; i++) {
|
|
861
|
-
id += chars[Math.floor(Math.random() * chars.length)];
|
|
862
|
-
}
|
|
863
|
-
return id;
|
|
961
|
+
return generateIdUtil(prefix);
|
|
864
962
|
}
|
|
865
963
|
|
|
866
964
|
/**
|
|
@@ -891,9 +989,11 @@ export class ConnectionDO implements DurableObject {
|
|
|
891
989
|
return;
|
|
892
990
|
}
|
|
893
991
|
|
|
992
|
+
const encrypted = msg.encrypted ? 1 : 0;
|
|
993
|
+
|
|
894
994
|
await this.env.DB.prepare(
|
|
895
|
-
`INSERT OR REPLACE INTO jobs (id, task_id, user_id, session_key, status, started_at, finished_at, duration_ms, summary)
|
|
896
|
-
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
995
|
+
`INSERT OR REPLACE INTO jobs (id, task_id, user_id, session_key, status, started_at, finished_at, duration_ms, summary, encrypted)
|
|
996
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
|
897
997
|
)
|
|
898
998
|
.bind(
|
|
899
999
|
jobId,
|
|
@@ -905,6 +1005,7 @@ export class ConnectionDO implements DurableObject {
|
|
|
905
1005
|
finishedAt ?? null,
|
|
906
1006
|
durationMs ?? null,
|
|
907
1007
|
summary,
|
|
1008
|
+
encrypted,
|
|
908
1009
|
)
|
|
909
1010
|
.run();
|
|
910
1011
|
} catch (err) {
|
|
@@ -913,23 +1014,42 @@ export class ConnectionDO implements DurableObject {
|
|
|
913
1014
|
}
|
|
914
1015
|
|
|
915
1016
|
private async validatePairingToken(token: string): Promise<boolean> {
|
|
916
|
-
//
|
|
917
|
-
//
|
|
918
|
-
//
|
|
919
|
-
// we store validated tokens in DO storage after first validation.
|
|
920
|
-
//
|
|
921
|
-
// Check DO-local cache first:
|
|
922
|
-
const cached = await this.state.storage.get<boolean>(`token:${token}`);
|
|
923
|
-
if (cached === true) return true;
|
|
924
|
-
if (cached === false) return false;
|
|
925
|
-
|
|
926
|
-
// If not cached, we accept the token optimistically and let the
|
|
927
|
-
// API worker validate it on the next REST call. In production,
|
|
928
|
-
// the API worker should validate before routing to the DO.
|
|
1017
|
+
// The API worker validates pairing tokens against D1 before routing
|
|
1018
|
+
// to the DO (and passes ?verified=1). Connections that arrive here
|
|
1019
|
+
// pre-verified are fast-tracked in handleOpenClawMessage.
|
|
929
1020
|
//
|
|
930
|
-
// For
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
1021
|
+
// For tokens that arrive WITHOUT pre-verification (e.g. direct DO
|
|
1022
|
+
// access, which shouldn't happen in normal flow), we validate
|
|
1023
|
+
// against D1 ourselves and cache the result with a TTL.
|
|
1024
|
+
|
|
1025
|
+
if (!token || !token.startsWith("bc_pat_") || token.length < 20) {
|
|
1026
|
+
return false;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
// Check DO-local cache first (with 30-second TTL — short to ensure
|
|
1030
|
+
// revoked tokens are invalidated quickly)
|
|
1031
|
+
const cacheKey = `token:${token}`;
|
|
1032
|
+
const cached = await this.state.storage.get<{ valid: boolean; cachedAt: number }>(cacheKey);
|
|
1033
|
+
if (cached) {
|
|
1034
|
+
const ageMs = Date.now() - cached.cachedAt;
|
|
1035
|
+
if (ageMs < 30_000) return cached.valid; // 30-second TTL
|
|
1036
|
+
// Expired — fall through to re-validate
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
// Validate against D1
|
|
1040
|
+
try {
|
|
1041
|
+
const row = await this.env.DB.prepare(
|
|
1042
|
+
"SELECT user_id FROM pairing_tokens WHERE token = ? AND revoked_at IS NULL",
|
|
1043
|
+
)
|
|
1044
|
+
.bind(token)
|
|
1045
|
+
.first<{ user_id: string }>();
|
|
1046
|
+
|
|
1047
|
+
const isValid = !!row;
|
|
1048
|
+
await this.state.storage.put(cacheKey, { valid: isValid, cachedAt: Date.now() });
|
|
1049
|
+
return isValid;
|
|
1050
|
+
} catch (err) {
|
|
1051
|
+
console.error("[DO] Failed to validate pairing token against D1:", err);
|
|
1052
|
+
return false;
|
|
1053
|
+
}
|
|
934
1054
|
}
|
|
935
1055
|
}
|