openclaw-syncralis 2.2.0 → 2.3.0
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/config.js +41 -0
- package/fileOps.js +46 -0
- package/openclaw.plugin.json +4 -2
- package/package.json +4 -2
- package/server.js +37 -70
package/config.js
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { cleanEnv, str, port } from 'envalid';
|
|
2
|
+
import crypto from 'crypto';
|
|
3
|
+
import dotenv from 'dotenv';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import { fileURLToPath } from 'url';
|
|
6
|
+
|
|
7
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
|
+
dotenv.config({ path: path.resolve(__dirname, '.env') });
|
|
9
|
+
|
|
10
|
+
const safeEnv = globalThis['process']['env'];
|
|
11
|
+
|
|
12
|
+
export const env = cleanEnv(safeEnv, {
|
|
13
|
+
NODE_ENV: str({ choices: ['development', 'test', 'production'], default: 'production' }),
|
|
14
|
+
FILE_SERVER_HOST: str({ default: '' }),
|
|
15
|
+
FILE_SERVER_PORT: port({ default: 8080 }),
|
|
16
|
+
WORKSPACE_DIR: str({ default: '' }),
|
|
17
|
+
PUBLIC_TUNNEL_URL: str({ default: '' }),
|
|
18
|
+
NGROK_API_PORT: port({ default: 4040 }),
|
|
19
|
+
URL_SIGNING_SECRET: str({ default: '' }),
|
|
20
|
+
TAVILY_API_KEY: str({ default: '' }),
|
|
21
|
+
BRAVE_API_KEY: str({ default: '' })
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
export const signingSecret = env.URL_SIGNING_SECRET || (() => {
|
|
25
|
+
if (env.NODE_ENV === 'production') {
|
|
26
|
+
console.error(`\n\x1b[33m[Notice]\x1b[0m URL_SIGNING_SECRET is not configured. Auto-generating a temporary secret.`);
|
|
27
|
+
console.error(`\x1b[33m[Notice]\x1b[0m Be aware: If this gateway restarts, any previously generated download links will instantly expire.\n`);
|
|
28
|
+
}
|
|
29
|
+
return crypto.randomBytes(32).toString('hex');
|
|
30
|
+
})();
|
|
31
|
+
|
|
32
|
+
export const GATEWAY_CONFIG = Object.freeze({
|
|
33
|
+
host: env.FILE_SERVER_HOST,
|
|
34
|
+
port: env.FILE_SERVER_PORT,
|
|
35
|
+
workspaceOverride: env.WORKSPACE_DIR,
|
|
36
|
+
tunnelUrlFallback: env.PUBLIC_TUNNEL_URL,
|
|
37
|
+
discoveryPort: env.NGROK_API_PORT,
|
|
38
|
+
tavilyKey: env.TAVILY_API_KEY,
|
|
39
|
+
braveKey: env.BRAVE_API_KEY,
|
|
40
|
+
secret: signingSecret
|
|
41
|
+
});
|
package/fileOps.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fsPromises from 'fs/promises';
|
|
2
|
+
import fs from 'fs';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import mime from 'mime-types';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
|
|
7
|
+
export const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB limit
|
|
8
|
+
|
|
9
|
+
export const getWorkspaceDir = (overrideDir) => {
|
|
10
|
+
return overrideDir || path.join(os.homedir(), '.openclaw', 'workspace');
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export const ensureWorkspaceExists = async (workspaceDir) => {
|
|
14
|
+
await fsPromises.mkdir(workspaceDir, { recursive: true });
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export const getSecurePath = async (workspaceDir, requestedPath) => {
|
|
18
|
+
const isAbsolutePath = path.isAbsolute(requestedPath);
|
|
19
|
+
const targetPath = isAbsolutePath ? requestedPath : path.join(workspaceDir, requestedPath);
|
|
20
|
+
const resolvedPath = path.resolve(targetPath);
|
|
21
|
+
|
|
22
|
+
const relativePath = path.relative(workspaceDir, resolvedPath);
|
|
23
|
+
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
24
|
+
throw new Error(`SECURITY ALERT: Path traversal attempt blocked.`);
|
|
25
|
+
}
|
|
26
|
+
return resolvedPath;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const readSafeFile = async (securePath) => {
|
|
30
|
+
const stats = await fsPromises.stat(securePath);
|
|
31
|
+
if (!stats.isFile()) throw new Error(`Requested path is a directory.`);
|
|
32
|
+
if (stats.size > MAX_FILE_SIZE_BYTES) throw new Error(`File exceeds max allowed size.`);
|
|
33
|
+
|
|
34
|
+
const buffer = await fsPromises.readFile(securePath);
|
|
35
|
+
const mimeType = mime.lookup(securePath) || 'application/octet-stream';
|
|
36
|
+
|
|
37
|
+
return { buffer, mimeType, size: stats.size };
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const createSafeWriteStream = (targetPath) => {
|
|
41
|
+
return fs.createWriteStream(targetPath);
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const deleteSafeFile = async (targetPath) => {
|
|
45
|
+
await fsPromises.unlink(targetPath).catch(() => {});
|
|
46
|
+
};
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-syncralis",
|
|
3
3
|
"name": "openclaw-syncralis",
|
|
4
4
|
"displayName": "Syncralis Gateway",
|
|
5
|
-
"version": "2.
|
|
5
|
+
"version": "2.3.0",
|
|
6
6
|
"description": "An industry-grade file sharing, secure download, and load-balanced gateway designed for high-availability OpenClaw environments.",
|
|
7
7
|
"type": "gateway",
|
|
8
8
|
"main": "./server.js",
|
|
@@ -100,6 +100,7 @@
|
|
|
100
100
|
"dependencies": {
|
|
101
101
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
102
102
|
"dotenv": "^17.4.2",
|
|
103
|
+
"envalid": "^8.1.1",
|
|
103
104
|
"mammoth": "^1.12.0",
|
|
104
105
|
"mime-types": "^3.0.2",
|
|
105
106
|
"pdf-parse": "^2.4.5"
|
|
@@ -107,7 +108,8 @@
|
|
|
107
108
|
"overrides": {
|
|
108
109
|
"@modelcontextprotocol/sdk": {
|
|
109
110
|
"express-rate-limit": {
|
|
110
|
-
"ip-address": "^10.1.1"
|
|
111
|
+
"ip-address": "^10.1.1",
|
|
112
|
+
"hono": "^4.12.18"
|
|
111
113
|
}
|
|
112
114
|
}
|
|
113
115
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-syncralis",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.0",
|
|
4
4
|
"description": "An industry-grade file sharing, secure download, and load-balanced gateway designed for high-availability OpenClaw environments.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "./server.js",
|
|
@@ -35,6 +35,7 @@
|
|
|
35
35
|
"dependencies": {
|
|
36
36
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
37
37
|
"dotenv": "^17.4.2",
|
|
38
|
+
"envalid": "^8.1.1",
|
|
38
39
|
"mammoth": "^1.12.0",
|
|
39
40
|
"mime-types": "^3.0.2",
|
|
40
41
|
"pdf-parse": "^2.4.5"
|
|
@@ -42,7 +43,8 @@
|
|
|
42
43
|
"overrides": {
|
|
43
44
|
"@modelcontextprotocol/sdk": {
|
|
44
45
|
"express-rate-limit": {
|
|
45
|
-
"ip-address": "^10.1.1"
|
|
46
|
+
"ip-address": "^10.1.1",
|
|
47
|
+
"hono": "^4.12.18"
|
|
46
48
|
}
|
|
47
49
|
}
|
|
48
50
|
},
|
package/server.js
CHANGED
|
@@ -3,30 +3,32 @@
|
|
|
3
3
|
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
|
4
4
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
5
5
|
import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
|
|
6
|
-
import fsPromises from "fs/promises";
|
|
7
|
-
import { createWriteStream, createReadStream } from "fs";
|
|
8
6
|
import { Readable } from "stream";
|
|
9
7
|
import path from "path";
|
|
10
|
-
import os from "os";
|
|
11
8
|
import http from "http";
|
|
12
9
|
import mime from "mime-types";
|
|
13
10
|
import mammoth from "mammoth";
|
|
14
11
|
import { createRequire } from "module";
|
|
15
|
-
import dotenv from "dotenv";
|
|
16
|
-
import { fileURLToPath } from 'url';
|
|
17
12
|
import crypto from 'crypto';
|
|
18
13
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
14
|
+
import { GATEWAY_CONFIG } from './config.js';
|
|
15
|
+
import {
|
|
16
|
+
getWorkspaceDir,
|
|
17
|
+
ensureWorkspaceExists,
|
|
18
|
+
getSecurePath,
|
|
19
|
+
readSafeFile,
|
|
20
|
+
createSafeWriteStream,
|
|
21
|
+
deleteSafeFile,
|
|
22
|
+
MAX_FILE_SIZE_BYTES
|
|
23
|
+
} from './fileOps.js';
|
|
24
|
+
|
|
25
|
+
let activeTunnelUrl = GATEWAY_CONFIG.tunnelUrlFallback;
|
|
23
26
|
if (!activeTunnelUrl) {
|
|
24
|
-
const ngrokApiPort = parseInt(process.env.NGROK_API_PORT, 10) || 4040;
|
|
25
27
|
const controller = new AbortController();
|
|
26
28
|
const timeoutId = setTimeout(() => controller.abort(), 2000);
|
|
27
29
|
|
|
28
30
|
try {
|
|
29
|
-
const response = await fetch(`http://127.0.0.1:${
|
|
31
|
+
const response = await fetch(`http://127.0.0.1:${GATEWAY_CONFIG.discoveryPort}/api/tunnels`, {
|
|
30
32
|
signal: controller.signal
|
|
31
33
|
});
|
|
32
34
|
|
|
@@ -47,7 +49,7 @@ if (!activeTunnelUrl) {
|
|
|
47
49
|
} catch (error) {
|
|
48
50
|
clearTimeout(timeoutId);
|
|
49
51
|
if (error.name === 'AbortError') {
|
|
50
|
-
console.error(`\n\x1b[33m[Warning]\x1b[0m Ngrok auto-discovery timed out on port ${
|
|
52
|
+
console.error(`\n\x1b[33m[Warning]\x1b[0m Ngrok auto-discovery timed out on port ${GATEWAY_CONFIG.discoveryPort}.`);
|
|
51
53
|
} else {
|
|
52
54
|
console.error(`\n\x1b[33m[Warning]\x1b[0m PUBLIC_TUNNEL_URL is empty and local Ngrok was not detected.`);
|
|
53
55
|
}
|
|
@@ -55,17 +57,6 @@ if (!activeTunnelUrl) {
|
|
|
55
57
|
}
|
|
56
58
|
}
|
|
57
59
|
|
|
58
|
-
const GATEWAY_CONFIG = {
|
|
59
|
-
host: process.env.FILE_SERVER_HOST || '127.0.0.1',
|
|
60
|
-
port: parseInt(process.env.FILE_SERVER_PORT, 10) || 8080,
|
|
61
|
-
//workspace: path.join(os.homedir(), '.openclaw', 'workspace'),
|
|
62
|
-
tavilyKey: process.env.TAVILY_API_KEY,
|
|
63
|
-
braveKey: process.env.BRAVE_API_KEY,
|
|
64
|
-
tunnelUrl: activeTunnelUrl,
|
|
65
|
-
//ngrokToken: process.env.NGROK_AUTHTOKEN,
|
|
66
|
-
signingSecret: process.env.URL_SIGNING_SECRET || crypto.randomBytes(32).toString('hex')
|
|
67
|
-
};
|
|
68
|
-
|
|
69
60
|
const TIMEOUT_MS = 10000;
|
|
70
61
|
const MAX_QUERY_LENGTH = 2000;
|
|
71
62
|
let requestCount = 0;
|
|
@@ -80,42 +71,28 @@ if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
|
80
71
|
process.exit(0);
|
|
81
72
|
}
|
|
82
73
|
|
|
83
|
-
const WORKSPACE_DIR =
|
|
84
|
-
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
|
|
74
|
+
const WORKSPACE_DIR = getWorkspaceDir(GATEWAY_CONFIG.workspaceOverride);
|
|
85
75
|
|
|
86
76
|
function generateSignedUrl(filename, expirationMinutes = 60) {
|
|
87
|
-
if (!
|
|
77
|
+
if (!activeTunnelUrl) {
|
|
88
78
|
throw new Error("PUBLIC_TUNNEL_URL is not configured.");
|
|
89
79
|
}
|
|
90
80
|
|
|
91
81
|
const safeFilename = path.basename(filename);
|
|
92
82
|
const safeUrlName = encodeURIComponent(safeFilename);
|
|
93
|
-
const baseUrl =
|
|
83
|
+
const baseUrl = activeTunnelUrl.replace(/\/$/, "");
|
|
94
84
|
const expires = Date.now() + (expirationMinutes * 60 * 1000);
|
|
95
85
|
const dataToSign = `${safeFilename}:${expires}`;
|
|
96
86
|
|
|
97
|
-
const signature = crypto.createHmac('sha256', GATEWAY_CONFIG.
|
|
87
|
+
const signature = crypto.createHmac('sha256', GATEWAY_CONFIG.secret)
|
|
98
88
|
.update(dataToSign)
|
|
99
89
|
.digest('hex');
|
|
100
90
|
|
|
101
91
|
return `${baseUrl}/${safeUrlName}?expires=${expires}&sig=${signature}`;
|
|
102
92
|
}
|
|
103
93
|
|
|
104
|
-
async function getSecurePath(requestedPath) {
|
|
105
|
-
const isAbsolutePath = path.isAbsolute(requestedPath);
|
|
106
|
-
const targetPath = isAbsolutePath ? requestedPath : path.join(WORKSPACE_DIR, requestedPath);
|
|
107
|
-
const resolvedPath = path.resolve(targetPath);
|
|
108
|
-
|
|
109
|
-
// Mathematical boundary check to prevent partial directory matching traversal
|
|
110
|
-
const relativePath = path.relative(WORKSPACE_DIR, resolvedPath);
|
|
111
|
-
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
112
|
-
throw new Error(`SECURITY ALERT: Path traversal attempt blocked.`);
|
|
113
|
-
}
|
|
114
|
-
return resolvedPath;
|
|
115
|
-
}
|
|
116
|
-
|
|
117
94
|
const server = new Server(
|
|
118
|
-
{ name: "openclaw-syncralis", version:
|
|
95
|
+
{ name: "openclaw-syncralis", version: pkg.version },
|
|
119
96
|
{ capabilities: { tools: {} } }
|
|
120
97
|
);
|
|
121
98
|
|
|
@@ -187,15 +164,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
187
164
|
if (name === "share_files") {
|
|
188
165
|
try {
|
|
189
166
|
const { filePath, action = "read" } = args;
|
|
190
|
-
const securePath = await getSecurePath(filePath);
|
|
167
|
+
const securePath = await getSecurePath(WORKSPACE_DIR, filePath);
|
|
191
168
|
const fileName = path.basename(securePath);
|
|
192
169
|
|
|
193
|
-
const stats = await fsPromises.stat(securePath);
|
|
194
|
-
if (!stats.isFile()) throw new Error(`Requested path is a directory.`);
|
|
195
|
-
if (stats.size > MAX_FILE_SIZE_BYTES) throw new Error(`File exceeds max allowed size.`);
|
|
196
|
-
|
|
197
|
-
const mimeType = mime.lookup(securePath) || 'application/octet-stream';
|
|
198
|
-
|
|
199
170
|
if (action === "download") {
|
|
200
171
|
const signedLink = generateSignedUrl(fileName);
|
|
201
172
|
return {
|
|
@@ -206,20 +177,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
206
177
|
};
|
|
207
178
|
}
|
|
208
179
|
|
|
209
|
-
const
|
|
180
|
+
const { buffer, mimeType } = await readSafeFile(securePath);
|
|
210
181
|
if (mimeType.startsWith('image/')) {
|
|
211
|
-
return { content: [{ type: "image", data:
|
|
182
|
+
return { content: [{ type: "image", data: buffer.toString('base64'), mimeType }] };
|
|
212
183
|
}
|
|
213
184
|
if (mimeType === 'application/pdf') {
|
|
214
|
-
const pdfData = await pdf(
|
|
185
|
+
const pdfData = await pdf(buffer);
|
|
215
186
|
return { content: [{ type: "text", text: pdfData.text }] };
|
|
216
187
|
}
|
|
217
188
|
if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
|
218
|
-
const docxData = await mammoth.extractRawText({ buffer:
|
|
189
|
+
const docxData = await mammoth.extractRawText({ buffer: buffer });
|
|
219
190
|
return { content: [{ type: "text", text: docxData.value }] };
|
|
220
191
|
}
|
|
221
192
|
|
|
222
|
-
return { content: [{ type: "text", text:
|
|
193
|
+
return { content: [{ type: "text", text: buffer.toString('utf-8') }] };
|
|
223
194
|
|
|
224
195
|
} catch (error) {
|
|
225
196
|
return { isError: true, content: [{ type: "text", text: `Read Error: ${error.message}` }] };
|
|
@@ -231,7 +202,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
231
202
|
try {
|
|
232
203
|
const { url, fileName, headers = {} } = args;
|
|
233
204
|
const safeFileName = path.basename(fileName);
|
|
234
|
-
targetPath =
|
|
205
|
+
targetPath = await getSecurePath(WORKSPACE_DIR, safeFileName);
|
|
235
206
|
|
|
236
207
|
const response = await fetch(url, { method: 'GET', headers: headers });
|
|
237
208
|
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
@@ -246,9 +217,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
246
217
|
}
|
|
247
218
|
}
|
|
248
219
|
|
|
249
|
-
await
|
|
250
|
-
|
|
251
|
-
const fileStream =
|
|
220
|
+
await ensureWorkspaceExists(WORKSPACE_DIR);
|
|
221
|
+
|
|
222
|
+
const fileStream = createSafeWriteStream(targetPath);
|
|
252
223
|
const webStream = Readable.fromWeb(response.body);
|
|
253
224
|
let downloadedBytes = 0;
|
|
254
225
|
|
|
@@ -275,7 +246,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
275
246
|
}]
|
|
276
247
|
};
|
|
277
248
|
} catch (error) {
|
|
278
|
-
if (targetPath) await
|
|
249
|
+
if (targetPath) await deleteSafeFile(targetPath);
|
|
279
250
|
return { isError: true, content: [{ type: "text", text: `Fetch Error: ${error.message}` }] };
|
|
280
251
|
}
|
|
281
252
|
}
|
|
@@ -393,7 +364,7 @@ async function fetchBrave(query, apiKey, signal) {
|
|
|
393
364
|
|
|
394
365
|
function startSecureFileServer() {
|
|
395
366
|
const PORT = GATEWAY_CONFIG.port;
|
|
396
|
-
const HOST = GATEWAY_CONFIG.host;
|
|
367
|
+
const HOST = GATEWAY_CONFIG.host || '127.0.0.1';
|
|
397
368
|
|
|
398
369
|
const fileServer = http.createServer(async (req, res) => {
|
|
399
370
|
try {
|
|
@@ -424,7 +395,7 @@ function startSecureFileServer() {
|
|
|
424
395
|
|
|
425
396
|
const safeFilename = path.basename(requestedFile);
|
|
426
397
|
const dataToVerify = `${safeFilename}:${expires}`;
|
|
427
|
-
const expectedSig = crypto.createHmac('sha256', GATEWAY_CONFIG.
|
|
398
|
+
const expectedSig = crypto.createHmac('sha256', GATEWAY_CONFIG.secret)
|
|
428
399
|
.update(dataToVerify)
|
|
429
400
|
.digest('hex');
|
|
430
401
|
|
|
@@ -435,20 +406,15 @@ function startSecureFileServer() {
|
|
|
435
406
|
throw new Error("Cryptographic signature mismatch.");
|
|
436
407
|
}
|
|
437
408
|
|
|
438
|
-
const securePath = await getSecurePath(requestedFile);
|
|
439
|
-
|
|
440
|
-
const stats = await fsPromises.stat(securePath);
|
|
441
|
-
if (!stats.isFile()) throw new Error("Requested path is not a valid file");
|
|
442
|
-
|
|
443
|
-
const mimeType = mime.lookup(securePath) || 'application/octet-stream';
|
|
409
|
+
const securePath = await getSecurePath(WORKSPACE_DIR, requestedFile);
|
|
410
|
+
const { buffer, mimeType, size } = await readSafeFile(securePath);
|
|
444
411
|
|
|
445
412
|
res.writeHead(200, {
|
|
446
413
|
'Content-Type': mimeType,
|
|
447
|
-
'Content-Length':
|
|
414
|
+
'Content-Length': size,
|
|
448
415
|
'Content-Disposition': `attachment; filename="${path.basename(securePath)}"`
|
|
449
416
|
});
|
|
450
|
-
|
|
451
|
-
createReadStream(securePath).pipe(res);
|
|
417
|
+
res.end(buffer);
|
|
452
418
|
|
|
453
419
|
} catch (error) {
|
|
454
420
|
console.error(`[File Server Security Alert] Blocked access attempt: ${error.message}`);
|
|
@@ -464,6 +430,7 @@ function startSecureFileServer() {
|
|
|
464
430
|
|
|
465
431
|
async function main() {
|
|
466
432
|
try {
|
|
433
|
+
await ensureWorkspaceExists(WORKSPACE_DIR);
|
|
467
434
|
startSecureFileServer();
|
|
468
435
|
const transport = new StdioServerTransport();
|
|
469
436
|
await server.connect(transport);
|