@clawchatsai/connector 0.0.86 → 0.0.88
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/LICENSE +661 -0
- package/README.md +67 -13
- package/dist/index.js +80 -89
- package/openclaw.plugin.json +1 -1
- package/package.json +11 -8
- package/prebuilds/darwin-arm64/node_datachannel.node +0 -0
- package/prebuilds/darwin-x64/node_datachannel.node +0 -0
- package/prebuilds/linux-arm/node_datachannel.node +0 -0
- package/prebuilds/linux-arm64/node_datachannel.node +0 -0
- package/prebuilds/linux-x64/node_datachannel.node +0 -0
- package/prebuilds/linuxmusl-arm64/node_datachannel.node +0 -0
- package/prebuilds/linuxmusl-x64/node_datachannel.node +0 -0
- package/prebuilds/win32-arm64/node_datachannel.node +0 -0
- package/prebuilds/win32-x64/node_datachannel.node +0 -0
- package/server/bootstrap/identity.js +47 -0
- package/server/bootstrap/native.js +9 -0
- package/server/config.js +63 -0
- package/server/controllers/agents.js +20 -0
- package/server/controllers/files.js +64 -0
- package/server/controllers/filesystem.js +139 -0
- package/server/controllers/memory.js +86 -0
- package/server/controllers/messages.js +128 -0
- package/server/controllers/settings.js +28 -0
- package/server/controllers/static.js +56 -0
- package/server/controllers/threads.js +113 -0
- package/server/controllers/transcribe.js +44 -0
- package/server/controllers/workspaces.js +102 -0
- package/server/debug.js +56 -0
- package/server/gateway-cleanup.js +47 -0
- package/server/gateway.js +331 -0
- package/server/index.js +397 -0
- package/server/providers/memory-config.js +52 -0
- package/server/providers/memory.js +108 -0
- package/server/store/workspace-store.js +31 -0
- package/server/util/context.js +49 -0
- package/server/util/helpers.js +111 -0
- package/server/util/http.js +57 -0
- package/server/util/multipart.js +46 -0
- package/dist/migrate.d.ts +0 -16
- package/dist/migrate.js +0 -114
- package/dist/updater.d.ts +0 -21
- package/dist/updater.js +0 -64
- package/server.js +0 -2459
package/README.md
CHANGED
|
@@ -1,30 +1,84 @@
|
|
|
1
1
|
# @clawchatsai/connector
|
|
2
2
|
|
|
3
|
-
OpenClaw plugin
|
|
3
|
+
OpenClaw plugin that bridges the [ClawChats](https://clawchats.ai) web app to your local OpenClaw gateway.
|
|
4
4
|
|
|
5
|
-
##
|
|
5
|
+
## What it does
|
|
6
|
+
|
|
7
|
+
When installed, this plugin runs as a background service alongside your OpenClaw gateway. It:
|
|
8
|
+
|
|
9
|
+
- Connects to the OpenClaw gateway WebSocket and persists conversation history to a local SQLite database
|
|
10
|
+
- Connects to the ClawChats signaling server (`wss://login.clawchats.ai`) to establish a P2P WebRTC session with your browser
|
|
11
|
+
- Once the P2P connection is established, your browser communicates directly with this plugin — no data passes through any external server
|
|
12
|
+
- Exposes a local HTTP/WebSocket API for threads, messages, workspaces, file management, and memory
|
|
13
|
+
|
|
14
|
+
## How the connection works
|
|
15
|
+
|
|
16
|
+
```
|
|
17
|
+
app.clawchats.ai ──── signaling server ──── connector plugin (your machine)
|
|
18
|
+
(browser UI) (handshake only) (OpenClaw gateway)
|
|
19
|
+
│ │
|
|
20
|
+
└──────── WebRTC DataChannel (P2P) ────────────┘
|
|
21
|
+
encrypted, direct, no relay
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
1. You open [app.clawchats.ai](https://app.clawchats.ai) in your browser
|
|
25
|
+
2. The browser authenticates with `login.clawchats.ai` and gets a session token
|
|
26
|
+
3. The signaling server coordinates a WebRTC handshake between your browser and this plugin
|
|
27
|
+
4. Your browser connects directly to your gateway via an encrypted P2P DataChannel
|
|
28
|
+
5. All conversation data — messages, files, memory — travels over that direct connection
|
|
29
|
+
|
|
30
|
+
The signaling server only facilitates the handshake. After step 4, it is out of the picture.
|
|
31
|
+
|
|
32
|
+
## Installation
|
|
6
33
|
|
|
7
34
|
```bash
|
|
8
35
|
openclaw plugins install @clawchatsai/connector
|
|
36
|
+
openclaw gateway restart
|
|
9
37
|
```
|
|
10
38
|
|
|
11
|
-
|
|
39
|
+
Then open [app.clawchats.ai](https://app.clawchats.ai) and follow the setup flow.
|
|
12
40
|
|
|
13
|
-
|
|
41
|
+
## Architecture
|
|
14
42
|
|
|
15
43
|
```
|
|
16
|
-
/
|
|
44
|
+
server/ # Local backend server (Node.js, plain ESM)
|
|
45
|
+
index.js # createApp() factory — HTTP API + WebSocket relay to OpenClaw
|
|
46
|
+
gateway.js # GatewayClient — maintains connection to local OpenClaw gateway
|
|
47
|
+
config.js # Config discovery (env vars → openclaw.json → defaults)
|
|
48
|
+
debug.js # Debug session logger
|
|
49
|
+
gateway-cleanup.js # Session cleanup on thread/workspace delete
|
|
50
|
+
bootstrap/
|
|
51
|
+
native.js # node:sqlite initialisation (built into Node ≥22.5, no compilation)
|
|
52
|
+
identity.js # ed25519 device signing for OpenClaw ≥2.15
|
|
53
|
+
controllers/ # HTTP route handlers (threads, messages, workspaces, files, memory)
|
|
54
|
+
providers/ # Memory backends (Qdrant, Postgres)
|
|
55
|
+
util/ # HTTP helpers, multipart parser, context builder, misc
|
|
56
|
+
src/ # OpenClaw plugin wrapper (TypeScript)
|
|
57
|
+
index.ts # Plugin entry point — registers with OpenClaw, manages lifecycle
|
|
58
|
+
signaling-client.ts # Connects to ClawChats signaling server for P2P handshake
|
|
59
|
+
webrtc-peer.ts # WebRTC DataChannel peer — direct browser ↔ gateway connection
|
|
60
|
+
auth-handler.ts # TOTP + Google session auth for DataChannel connections
|
|
17
61
|
```
|
|
18
62
|
|
|
19
|
-
|
|
63
|
+
## Security & permissions
|
|
20
64
|
|
|
21
|
-
|
|
65
|
+
**Filesystem access:** The plugin reads/writes under `~/.openclaw/` (gateway data) and the user's home directory for workspace file browsing. File serving is restricted to an explicit allowlist (`HOME`, `/tmp`). Write access is limited to within `HOME`.
|
|
22
66
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
67
|
+
**Session cleanup:** When a thread or workspace is deleted, the plugin removes the associated OpenClaw session files (`.jsonl`) to prevent stale context. These are files created by the gateway itself during that session.
|
|
68
|
+
|
|
69
|
+
**Auth:** The DataChannel connection is authenticated with TOTP + Google session token before any data is processed. The HTTP API uses a local bearer token (set via `CLAWCHATS_AUTH_TOKEN` or `config.js`). No credentials are sent to external servers.
|
|
70
|
+
|
|
71
|
+
**No shell execution:** The plugin contains no `exec`, `spawn`, or shell calls. SQLite is handled by Node's built-in `node:sqlite` module — no native binary compilation required.
|
|
72
|
+
|
|
73
|
+
## Configuration
|
|
74
|
+
|
|
75
|
+
Config is read in priority order:
|
|
76
|
+
1. Environment variables (`CLAWCHATS_AUTH_TOKEN`, `GATEWAY_WS_URL`, `OPENCLAW_SESSIONS_DIR`)
|
|
77
|
+
2. `~/.openclaw/openclaw.json` (OpenClaw gateway config)
|
|
78
|
+
3. `config.js` in the plugin directory (local override, gitignored)
|
|
79
|
+
|
|
80
|
+
## License
|
|
26
81
|
|
|
27
|
-
|
|
82
|
+
[AGPL-3.0-only](LICENSE) — source is open for audit and contribution.
|
|
28
83
|
|
|
29
|
-
|
|
30
|
-
- [npm](https://www.npmjs.com/package/@clawchatsai/connector)
|
|
84
|
+
For commercial licensing, contact [clawchats.ai](https://clawchats.ai).
|
package/dist/index.js
CHANGED
|
@@ -11,10 +11,10 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import * as fs from 'node:fs';
|
|
13
13
|
import * as http from 'node:http';
|
|
14
|
+
import * as os from 'node:os';
|
|
14
15
|
import * as path from 'node:path';
|
|
15
16
|
import { SignalingClient } from './signaling-client.js';
|
|
16
17
|
import { dispatchRpc } from './shim.js';
|
|
17
|
-
import { checkForUpdates, performUpdate } from './updater.js';
|
|
18
18
|
import { initAuth, handleAuthMessage, cleanupAuth } from './auth-handler.js';
|
|
19
19
|
import { generateTotpSecret, verifyTotp, generateBackupCodes, buildOtpauthUri } from './totp.js';
|
|
20
20
|
import { generateSessionSecret } from './session-token.js';
|
|
@@ -66,64 +66,58 @@ function saveConfig(config) {
|
|
|
66
66
|
// ---------------------------------------------------------------------------
|
|
67
67
|
// Service lifecycle
|
|
68
68
|
// ---------------------------------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Detect musl libc (Alpine Linux) vs glibc.
|
|
71
|
+
* prebuildify/prebuild-install distinguishes linux vs linuxmusl.
|
|
72
|
+
*/
|
|
73
|
+
function detectLinuxLibc() {
|
|
74
|
+
try {
|
|
75
|
+
const ldd = fs.readFileSync('/usr/bin/ldd', 'utf8');
|
|
76
|
+
if (ldd.includes('musl'))
|
|
77
|
+
return 'musl';
|
|
78
|
+
}
|
|
79
|
+
catch { /* not found */ }
|
|
80
|
+
try {
|
|
81
|
+
if (fs.readdirSync('/lib').some((f) => f.startsWith('libc.musl')))
|
|
82
|
+
return 'musl';
|
|
83
|
+
}
|
|
84
|
+
catch { /* not found */ }
|
|
85
|
+
return 'glibc';
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Returns the prebuild key for the current platform, matching the directory
|
|
89
|
+
* names we bundle under prebuilds/ (e.g. "linux-x64", "linuxmusl-arm64").
|
|
90
|
+
*/
|
|
91
|
+
function getPrebuildKey() {
|
|
92
|
+
const platform = process.platform;
|
|
93
|
+
const arch = process.arch;
|
|
94
|
+
if (platform === 'linux' && detectLinuxLibc() === 'musl')
|
|
95
|
+
return `linuxmusl-${arch}`;
|
|
96
|
+
return `${platform}-${arch}`;
|
|
97
|
+
}
|
|
69
98
|
async function ensureNativeModules(ctx) {
|
|
70
|
-
// OpenClaw installs plugins with --ignore-scripts, which skips native module compilation.
|
|
71
|
-
// Check if native modules are usable; if not, rebuild them automatically.
|
|
72
99
|
const pluginDir = path.resolve(__dirname, '..');
|
|
73
|
-
const
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const localBin = [
|
|
95
|
-
path.join(modDir, 'node_modules', '.bin'),
|
|
96
|
-
path.join(pluginDir, 'node_modules', '.bin'),
|
|
97
|
-
].join(':');
|
|
98
|
-
execFileSync('sh', ['-c', installCmd], {
|
|
99
|
-
cwd: modDir,
|
|
100
|
-
stdio: 'pipe',
|
|
101
|
-
timeout: 120_000,
|
|
102
|
-
env: { ...process.env, PATH: `${localBin}:${process.env.PATH ?? ''}`, npm_config_node_gyp: '' },
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
else {
|
|
106
|
-
// Fallback to rebuild if no install script found
|
|
107
|
-
execFileSync('npm', ['rebuild', mod.name], {
|
|
108
|
-
cwd: pluginDir,
|
|
109
|
-
stdio: 'pipe',
|
|
110
|
-
timeout: 120_000,
|
|
111
|
-
});
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
else {
|
|
115
|
-
execFileSync('npm', ['rebuild', mod.name], {
|
|
116
|
-
cwd: pluginDir,
|
|
117
|
-
stdio: 'pipe',
|
|
118
|
-
timeout: 120_000,
|
|
119
|
-
});
|
|
120
|
-
}
|
|
121
|
-
ctx.logger.info(`${mod.name} ready.`);
|
|
122
|
-
}
|
|
123
|
-
catch (e) {
|
|
124
|
-
ctx.logger.error(`Failed to build ${mod.name}: ${e.message}`);
|
|
125
|
-
ctx.logger.error(`Try manually: cd ~/.openclaw/extensions/connector && npm rebuild ${mod.name}`);
|
|
126
|
-
}
|
|
100
|
+
const targetPath = path.join(pluginDir, 'node_modules', 'node-datachannel', 'build', 'Release', 'node_datachannel.node');
|
|
101
|
+
// Already built — nothing to do.
|
|
102
|
+
if (fs.existsSync(targetPath))
|
|
103
|
+
return;
|
|
104
|
+
// Find the bundled prebuilt for this platform (shipped inside the npm package).
|
|
105
|
+
const prebuildKey = getPrebuildKey();
|
|
106
|
+
const prebuiltPath = path.join(pluginDir, 'prebuilds', prebuildKey, 'node_datachannel.node');
|
|
107
|
+
if (!fs.existsSync(prebuiltPath)) {
|
|
108
|
+
ctx.logger.error(`[clawchats] No prebuilt binary for ${prebuildKey}. ` +
|
|
109
|
+
`WebRTC will be unavailable. To fix manually: ` +
|
|
110
|
+
`cd ~/.openclaw/extensions/connector && npm rebuild node-datachannel`);
|
|
111
|
+
return;
|
|
112
|
+
}
|
|
113
|
+
ctx.logger.info(`[clawchats] Installing node-datachannel prebuilt for ${prebuildKey}...`);
|
|
114
|
+
try {
|
|
115
|
+
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
|
116
|
+
fs.copyFileSync(prebuiltPath, targetPath);
|
|
117
|
+
ctx.logger.info('[clawchats] node-datachannel ready.');
|
|
118
|
+
}
|
|
119
|
+
catch (e) {
|
|
120
|
+
ctx.logger.error(`[clawchats] Failed to install prebuilt: ${e.message}`);
|
|
127
121
|
}
|
|
128
122
|
}
|
|
129
123
|
const CLAWCHATS_MD_CONTENT = `# ClawChats — Inline File Delivery
|
|
@@ -172,23 +166,7 @@ async function startClawChats(ctx, api, mediaStash) {
|
|
|
172
166
|
return;
|
|
173
167
|
ctx.logger.info('Setup detected — connecting to ClawChats...');
|
|
174
168
|
}
|
|
175
|
-
// 1.
|
|
176
|
-
const update = await checkForUpdates();
|
|
177
|
-
if (update) {
|
|
178
|
-
ctx.logger.info(`Update available: ${update.current} → ${update.latest}`);
|
|
179
|
-
if (ctx._forceUpdate) {
|
|
180
|
-
try {
|
|
181
|
-
await performUpdate();
|
|
182
|
-
ctx.logger.info(`Updated to ${update.latest}. Requesting graceful restart...`);
|
|
183
|
-
api.runtime.requestRestart?.('clawchats update');
|
|
184
|
-
return; // will restart with new version
|
|
185
|
-
}
|
|
186
|
-
catch (e) {
|
|
187
|
-
ctx.logger.error(`Auto-update failed: ${e.message}`);
|
|
188
|
-
}
|
|
189
|
-
}
|
|
190
|
-
}
|
|
191
|
-
// 2. Resolve gateway token: runtime API → config file → error
|
|
169
|
+
// 1. Resolve gateway token: runtime API → config file → error
|
|
192
170
|
const gwCfg = api.config;
|
|
193
171
|
const gwAuth = gwCfg?.['gateway']?.['auth'];
|
|
194
172
|
const gatewayToken = gwAuth?.['token'] || config.gatewayToken || '';
|
|
@@ -223,14 +201,38 @@ async function startClawChats(ctx, api, mediaStash) {
|
|
|
223
201
|
const uploadsDir = path.join(ctx.stateDir, 'clawchats', 'uploads');
|
|
224
202
|
_uploadsDir = uploadsDir;
|
|
225
203
|
// Dynamic import of server.js (plain JS, no type declarations)
|
|
226
|
-
// @ts-expect-error — server.js is plain JS with no .d.ts
|
|
227
|
-
const serverModule = await import('../server.js');
|
|
204
|
+
// @ts-expect-error — server/index.js is plain JS with no .d.ts
|
|
205
|
+
const serverModule = await import('../server/index.js');
|
|
206
|
+
// Read env vars here (plugin host) so server/ bundle stays process.env-free.
|
|
207
|
+
const memoryEnv = {
|
|
208
|
+
provider: process.env.MEMORY_PROVIDER,
|
|
209
|
+
host: process.env.MEMORY_HOST || process.env.QDRANT_HOST,
|
|
210
|
+
port: process.env.MEMORY_PORT || process.env.QDRANT_PORT,
|
|
211
|
+
collection: process.env.MEMORY_COLLECTION || process.env.QDRANT_COLLECTION,
|
|
212
|
+
pgUrl: process.env.MEMORY_PG_URL,
|
|
213
|
+
qdrantUrl: process.env.QDRANT_URL,
|
|
214
|
+
};
|
|
215
|
+
// Filter out undefined values so discoverMemoryConfig only overrides what's set.
|
|
216
|
+
const memoryEnvFiltered = Object.fromEntries(Object.entries(memoryEnv).filter(([, v]) => v !== undefined && v !== ''));
|
|
228
217
|
app = serverModule.createApp({
|
|
229
218
|
dataDir,
|
|
230
219
|
uploadsDir,
|
|
231
|
-
|
|
232
|
-
|
|
220
|
+
port: parseInt(process.env.PORT || '3001', 10),
|
|
221
|
+
gatewayUrl: process.env.GATEWAY_WS_URL || 'ws://localhost:18789',
|
|
222
|
+
authToken: process.env.CLAWCHATS_AUTH_TOKEN || '', // P2P: DataChannel is the auth boundary
|
|
233
223
|
gatewayToken, // For WS auth to local OpenClaw gateway
|
|
224
|
+
openaiApiKey: (() => {
|
|
225
|
+
// Resolve OpenAI API key: openclaw config → env var
|
|
226
|
+
try {
|
|
227
|
+
const oc = JSON.parse(fs.readFileSync(path.join(os.homedir(), '.openclaw', 'openclaw.json'), 'utf8'));
|
|
228
|
+
const fromConfig = oc?.skills?.entries?.['openai-whisper-api']?.apiKey;
|
|
229
|
+
if (fromConfig)
|
|
230
|
+
return fromConfig;
|
|
231
|
+
}
|
|
232
|
+
catch { /* ok */ }
|
|
233
|
+
return process.env.OPENAI_API_KEY || null;
|
|
234
|
+
})(),
|
|
235
|
+
memoryEnv: memoryEnvFiltered,
|
|
234
236
|
mediaStash, // Shared Map populated by after_tool_call hook (captures MEDIA: paths from exec)
|
|
235
237
|
});
|
|
236
238
|
// 4. Connect createApp's gateway client (handles persistence + event relay)
|
|
@@ -264,17 +266,6 @@ async function startClawChats(ctx, api, mediaStash) {
|
|
|
264
266
|
ctx.logger.error(`Signaling auth rejected: ${reason}`);
|
|
265
267
|
});
|
|
266
268
|
// version-rejected listener removed — version check is now client-side
|
|
267
|
-
signaling.on('force-update', async (targetVersion) => {
|
|
268
|
-
ctx.logger.info(`Force update to ${targetVersion} requested`);
|
|
269
|
-
try {
|
|
270
|
-
await performUpdate();
|
|
271
|
-
ctx.logger.info('Update complete, requesting restart');
|
|
272
|
-
api.runtime.requestRestart?.('forced update');
|
|
273
|
-
}
|
|
274
|
-
catch (e) {
|
|
275
|
-
ctx.logger.error(`Force update failed: ${e.message}`);
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
269
|
signaling.on('account-suspended', (reason) => {
|
|
279
270
|
ctx.logger.error(`Account suspended: ${reason}`);
|
|
280
271
|
broadcastToClients({ type: 'account-suspended', reason });
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
|
@@ -1,30 +1,28 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clawchatsai/connector",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.88",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "ClawChats OpenClaw plugin — P2P tunnel + local API bridge",
|
|
6
6
|
"main": "dist/index.js",
|
|
7
7
|
"types": "dist/index.d.ts",
|
|
8
8
|
"files": [
|
|
9
9
|
"dist",
|
|
10
|
-
"server
|
|
11
|
-
"openclaw.plugin.json"
|
|
10
|
+
"server/",
|
|
11
|
+
"openclaw.plugin.json",
|
|
12
|
+
"prebuilds/"
|
|
12
13
|
],
|
|
13
14
|
"publishConfig": {
|
|
14
15
|
"access": "public"
|
|
15
16
|
},
|
|
16
17
|
"engines": {
|
|
17
|
-
"node": ">=
|
|
18
|
+
"node": ">=22.5.0"
|
|
18
19
|
},
|
|
19
20
|
"scripts": {
|
|
20
|
-
"prebuild": "node esbuild.config.mjs",
|
|
21
21
|
"build": "tsc",
|
|
22
22
|
"dev": "tsc --watch",
|
|
23
|
-
"prepublishOnly": "npm run build"
|
|
24
|
-
"postinstall": "npm rebuild better-sqlite3 2>/dev/null || true"
|
|
23
|
+
"prepublishOnly": "npm run build"
|
|
25
24
|
},
|
|
26
25
|
"dependencies": {
|
|
27
|
-
"better-sqlite3": ">=9.0.0",
|
|
28
26
|
"jose": "^5.10.0",
|
|
29
27
|
"node-datachannel": "^0.32.1",
|
|
30
28
|
"ws": "^8.0.0"
|
|
@@ -38,5 +36,10 @@
|
|
|
38
36
|
"@types/ws": "^8.0.0",
|
|
39
37
|
"esbuild": "^0.27.4",
|
|
40
38
|
"typescript": "^5.4.0"
|
|
39
|
+
},
|
|
40
|
+
"license": "AGPL-3.0-only",
|
|
41
|
+
"repository": {
|
|
42
|
+
"type": "git",
|
|
43
|
+
"url": "https://github.com/clawchatsai/connector"
|
|
41
44
|
}
|
|
42
45
|
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
|
|
5
|
+
const ED25519_SPKI_PREFIX = Buffer.from('302a300506032b6570032100', 'hex');
|
|
6
|
+
|
|
7
|
+
function derivePublicKeyRaw(publicKeyPem) {
|
|
8
|
+
const spki = crypto.createPublicKey(publicKeyPem).export({ type: 'spki', format: 'der' });
|
|
9
|
+
if (spki.length === ED25519_SPKI_PREFIX.length + 32 &&
|
|
10
|
+
spki.subarray(0, ED25519_SPKI_PREFIX.length).equals(ED25519_SPKI_PREFIX)) {
|
|
11
|
+
return spki.subarray(ED25519_SPKI_PREFIX.length);
|
|
12
|
+
}
|
|
13
|
+
return spki;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function fingerprintPublicKey(publicKeyPem) {
|
|
17
|
+
return crypto.createHash('sha256').update(derivePublicKeyRaw(publicKeyPem)).digest('hex');
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function base64UrlEncode(buf) {
|
|
21
|
+
return buf.toString('base64').replaceAll('+', '-').replaceAll('/', '_').replace(/=+$/g, '');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function loadOrCreateDeviceIdentity(identityPath) {
|
|
25
|
+
try {
|
|
26
|
+
if (fs.existsSync(identityPath)) {
|
|
27
|
+
const parsed = JSON.parse(fs.readFileSync(identityPath, 'utf8'));
|
|
28
|
+
if (parsed?.version === 1 && parsed.deviceId && parsed.publicKeyPem && parsed.privateKeyPem) return parsed;
|
|
29
|
+
}
|
|
30
|
+
} catch { /* regenerate */ }
|
|
31
|
+
const { publicKey, privateKey } = crypto.generateKeyPairSync('ed25519');
|
|
32
|
+
const publicKeyPem = publicKey.export({ type: 'spki', format: 'pem' }).toString();
|
|
33
|
+
const privateKeyPem = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString();
|
|
34
|
+
const identity = { version: 1, deviceId: fingerprintPublicKey(publicKeyPem), publicKeyPem, privateKeyPem, createdAtMs: Date.now() };
|
|
35
|
+
fs.mkdirSync(path.dirname(identityPath), { recursive: true });
|
|
36
|
+
fs.writeFileSync(identityPath, JSON.stringify(identity, null, 2) + '\n', { mode: 0o600 });
|
|
37
|
+
return identity;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function buildDeviceAuth(identity, { clientId, clientMode, role, scopes, token, nonce }) {
|
|
41
|
+
const signedAt = Date.now();
|
|
42
|
+
const payload = ['v2', identity.deviceId, clientId, clientMode, role, scopes.join(','), String(signedAt), token || '', nonce].join('|');
|
|
43
|
+
const privateKey = crypto.createPrivateKey(identity.privateKeyPem);
|
|
44
|
+
const signature = base64UrlEncode(crypto.sign(null, Buffer.from(payload, 'utf8'), privateKey));
|
|
45
|
+
const publicKeyB64Url = base64UrlEncode(derivePublicKeyRaw(identity.publicKeyPem));
|
|
46
|
+
return { id: identity.deviceId, publicKey: publicKeyB64Url, signature, signedAt, nonce };
|
|
47
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { DatabaseSync as Database } from 'node:sqlite';
|
|
2
|
+
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
3
|
+
|
|
4
|
+
// Per-request workspace DB — isolates concurrent clients on different workspaces.
|
|
5
|
+
export const requestDbStore = new AsyncLocalStorage();
|
|
6
|
+
|
|
7
|
+
// node:sqlite is a built-in Node.js module (available since Node 22.5).
|
|
8
|
+
// No native compilation required — SQLite is bundled directly into Node.
|
|
9
|
+
export { Database };
|
package/server/config.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
|
|
6
|
+
export const HOME = os.homedir();
|
|
7
|
+
export const MAX_PREAMBLE_CHARS = 50000;
|
|
8
|
+
|
|
9
|
+
// Resolve __dirname for ESM (esbuild inlines this correctly)
|
|
10
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
11
|
+
const __dirname = path.dirname(__filename);
|
|
12
|
+
|
|
13
|
+
export function parseConfigField(field) {
|
|
14
|
+
// Try both plugin root and parent of server/ — handles bundled and standalone modes
|
|
15
|
+
const candidates = [path.join(__dirname, 'config.js'), path.join(__dirname, '..', 'config.js')];
|
|
16
|
+
for (const configPath of candidates) {
|
|
17
|
+
try {
|
|
18
|
+
const configText = fs.readFileSync(configPath, 'utf8');
|
|
19
|
+
const match = configText.match(new RegExp(`${field}:\\s*['"]([^'"]+)['"]`));
|
|
20
|
+
if (match) return match[1];
|
|
21
|
+
} catch { /* try next */ }
|
|
22
|
+
}
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
// Auth token: config.js → empty (open/unauthenticated mode)
|
|
27
|
+
// Note: CLAWCHATS_AUTH_TOKEN env var is read by the plugin host (src/index.ts) and passed via createApp().
|
|
28
|
+
export const AUTH_TOKEN = parseConfigField('authToken') || '';
|
|
29
|
+
|
|
30
|
+
// Gateway WebSocket URL — uses the internal/local gateway address, NOT config.js gatewayUrl
|
|
31
|
+
// (that's the browser's external-facing URL and would cause a routing loop through Caddy)
|
|
32
|
+
// Note: GATEWAY_WS_URL env var is read by the plugin host (src/index.ts) and passed via createApp().
|
|
33
|
+
export function discoverGatewayWsUrl() {
|
|
34
|
+
for (const cfgPath of [path.join(HOME, '.openclaw', 'openclaw.json'), '/etc/openclaw/openclaw.json']) {
|
|
35
|
+
try {
|
|
36
|
+
const raw = JSON.parse(fs.readFileSync(cfgPath, 'utf8'));
|
|
37
|
+
const port = raw.gateway?.port || raw.port;
|
|
38
|
+
const host = raw.gateway?.host || raw.host || 'localhost';
|
|
39
|
+
if (port) return `ws://${host}:${port}`;
|
|
40
|
+
} catch { /* try next */ }
|
|
41
|
+
}
|
|
42
|
+
return 'ws://localhost:18789';
|
|
43
|
+
}
|
|
44
|
+
export const GATEWAY_WS_URL = discoverGatewayWsUrl();
|
|
45
|
+
|
|
46
|
+
// Sessions directory — where OpenClaw stores session .jsonl files
|
|
47
|
+
// Note: OPENCLAW_SESSIONS_DIR env var is read by the plugin host (src/index.ts) and passed via createApp().
|
|
48
|
+
export const OPENCLAW_SESSIONS_DIR =
|
|
49
|
+
parseConfigField('sessionsDir') ||
|
|
50
|
+
path.join(HOME, '.openclaw', 'agents', 'main', 'sessions');
|
|
51
|
+
|
|
52
|
+
export function getSessionsDirForAgent(agentId) {
|
|
53
|
+
if (!agentId || agentId === 'main') return OPENCLAW_SESSIONS_DIR;
|
|
54
|
+
return path.join(HOME, '.openclaw', 'agents', agentId, 'sessions');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function validateAgent(agentId) {
|
|
58
|
+
if (!agentId) return 'main';
|
|
59
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) throw new Error('Invalid agent ID');
|
|
60
|
+
const agentDir = path.join(HOME, '.openclaw', 'agents', agentId);
|
|
61
|
+
if (!fs.existsSync(agentDir)) throw new Error(`Agent not found: ${agentId}`);
|
|
62
|
+
return agentId;
|
|
63
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import os from 'node:os';
|
|
4
|
+
import { send } from '../util/http.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* List available OpenClaw agents.
|
|
8
|
+
* Keeps fs.readdirSync out of the HTTP router (server/index.js).
|
|
9
|
+
*/
|
|
10
|
+
export function handleAgents(req, res) {
|
|
11
|
+
try {
|
|
12
|
+
const agentsDir = path.join(os.homedir(), '.openclaw', 'agents');
|
|
13
|
+
const agents = fs.readdirSync(agentsDir, { withFileTypes: true })
|
|
14
|
+
.filter(e => e.isDirectory())
|
|
15
|
+
.map(e => e.name);
|
|
16
|
+
send(res, 200, { agents });
|
|
17
|
+
} catch {
|
|
18
|
+
send(res, 200, { agents: ['main'] });
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { send, sendError, parseBody, uuid } from '../util/http.js';
|
|
4
|
+
import { parseMultipart } from '../util/multipart.js';
|
|
5
|
+
|
|
6
|
+
export class FileController {
|
|
7
|
+
constructor({ getActiveDb, getWorkspaces, uploadsDir, intelligenceDir }) {
|
|
8
|
+
this.getActiveDb = getActiveDb;
|
|
9
|
+
this.getWorkspaces = getWorkspaces;
|
|
10
|
+
this.uploadsDir = uploadsDir;
|
|
11
|
+
this.intelligenceDir = intelligenceDir;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
async upload(req, res, params) {
|
|
15
|
+
if (!this.getActiveDb().prepare('SELECT id FROM threads WHERE id = ?').get(params.id)) return sendError(res, 404, 'Thread not found');
|
|
16
|
+
const files = await parseMultipart(req);
|
|
17
|
+
const dir = path.join(this.uploadsDir, params.id);
|
|
18
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
19
|
+
const savedFiles = [];
|
|
20
|
+
for (const file of files) {
|
|
21
|
+
const fileId = uuid();
|
|
22
|
+
const ext = path.extname(file.filename) || '';
|
|
23
|
+
fs.writeFileSync(path.join(dir, fileId + ext), file.data);
|
|
24
|
+
savedFiles.push({ id: fileId, filename: file.filename, path: `/api/uploads/${params.id}/${fileId}${ext}`, mimeType: file.mimeType, size: file.data.length });
|
|
25
|
+
}
|
|
26
|
+
send(res, 200, { files: savedFiles });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
serveUpload(req, res, params) {
|
|
30
|
+
const base = path.join(this.uploadsDir, params.threadId, params.fileId);
|
|
31
|
+
let resolved = base;
|
|
32
|
+
if (!fs.existsSync(resolved)) {
|
|
33
|
+
try {
|
|
34
|
+
const match = fs.readdirSync(path.join(this.uploadsDir, params.threadId)).find(e => e.startsWith(params.fileId.replace(/\.[^.]+$/, '')));
|
|
35
|
+
if (match) resolved = path.join(this.uploadsDir, params.threadId, match);
|
|
36
|
+
} catch { /* ok */ }
|
|
37
|
+
}
|
|
38
|
+
if (!fs.existsSync(resolved)) return sendError(res, 404, 'File not found');
|
|
39
|
+
const MIME = { '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif', '.webp': 'image/webp', '.pdf': 'application/pdf', '.txt': 'text/plain', '.json': 'application/json' };
|
|
40
|
+
const stat = fs.statSync(resolved);
|
|
41
|
+
res.writeHead(200, { 'Content-Type': MIME[path.extname(resolved).toLowerCase()] || 'application/octet-stream', 'Content-Length': stat.size, 'Cache-Control': 'public, max-age=86400', 'Access-Control-Allow-Origin': '*' });
|
|
42
|
+
fs.createReadStream(resolved).pipe(res);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
_intelligencePath(threadId) {
|
|
46
|
+
return path.join(this.intelligenceDir, this.getWorkspaces().active, `${threadId}.json`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
getIntelligence(req, res, params) {
|
|
50
|
+
const filePath = this._intelligencePath(params.id);
|
|
51
|
+
if (!fs.existsSync(filePath)) return send(res, 200, { versions: [], currentVersion: -1 });
|
|
52
|
+
try { return send(res, 200, JSON.parse(fs.readFileSync(filePath, 'utf8'))); }
|
|
53
|
+
catch { return send(res, 200, { versions: [], currentVersion: -1 }); }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async saveIntelligence(req, res, params) {
|
|
57
|
+
const body = await parseBody(req);
|
|
58
|
+
const filePath = this._intelligencePath(params.id);
|
|
59
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
60
|
+
const data = { versions: body.versions || [], currentVersion: body.currentVersion ?? -1, updatedAt: Date.now() };
|
|
61
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
62
|
+
send(res, 200, data);
|
|
63
|
+
}
|
|
64
|
+
}
|