openclaw-syncralis 2.3.0 → 2.3.4

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/.env.example CHANGED
File without changes
package/README.md CHANGED
@@ -17,7 +17,7 @@ Syncralis provides load-balanced web searching, secure file downloads, and mobil
17
17
  ***Path Boundary Enforcement:** Cryptographically verifies all file requests to prevent directory traversal attacks outside the designated workspace.
18
18
 
19
19
 
20
- ## 🔑 Prerequisites & Free Tiers
20
+ ## 🔑 Requirements & API Keys (Free Tiers)
21
21
 
22
22
  Syncralis relies on three external services. Each of these providers offers a generous free tier for developers (subject to their respective Terms and Conditions):
23
23
 
@@ -107,7 +107,16 @@ When running OpenClaw natively on your host machine, Syncralis spins up a secure
107
107
  "WORKSPACE_DIR": "",
108
108
  "PUBLIC_TUNNEL_URL": "https://your-ngrok-url.ngrok-free.app",
109
109
  "NGROK_API_PORT": 4040,
110
- "URL_SIGNING_SECRET": "your_custom_32_character_secret_here",
110
+ "URL_SIGNING_SECRET": "your_custom_32_character_secret_here"
111
+ }
112
+ }
113
+ }
114
+ },
115
+ "plugins": {
116
+ "entries": {
117
+ "openclaw-syncralis": {
118
+ "enabled": true,
119
+ "config": {
111
120
  "TAVILY_API_KEY": "your_tavily_key",
112
121
  "BRAVE_API_KEY": "your_brave_key"
113
122
  }
@@ -137,7 +146,16 @@ OpenClaw often executes tools as ephemeral child processes. In a containerized s
137
146
  "WORKSPACE_DIR": "",
138
147
  "PUBLIC_TUNNEL_URL": "https://your-static-domain.ngrok-free.app",
139
148
  "NGROK_API_PORT": 4040,
140
- "URL_SIGNING_SECRET": "your_custom_32_character_secret_here",
149
+ "URL_SIGNING_SECRET": "your_custom_32_character_secret_here"
150
+ }
151
+ }
152
+ }
153
+ },
154
+ "plugins": {
155
+ "entries": {
156
+ "openclaw-syncralis": {
157
+ "enabled": true,
158
+ "config": {
141
159
  "TAVILY_API_KEY": "your_tavily_key",
142
160
  "BRAVE_API_KEY": "your_brave_key"
143
161
  }
package/config.js CHANGED
File without changes
package/fileOps.js CHANGED
@@ -4,7 +4,7 @@ import path from 'path';
4
4
  import mime from 'mime-types';
5
5
  import os from 'os';
6
6
 
7
- export const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024; // 50MB limit
7
+ export const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
8
8
 
9
9
  export const getWorkspaceDir = (overrideDir) => {
10
10
  return overrideDir || path.join(os.homedir(), '.openclaw', 'workspace');
@@ -1,117 +1,117 @@
1
- {
2
- "id": "openclaw-syncralis",
3
- "name": "openclaw-syncralis",
4
- "displayName": "Syncralis Gateway",
5
- "version": "2.3.0",
6
- "description": "An industry-grade file sharing, secure download, and load-balanced gateway designed for high-availability OpenClaw environments.",
7
- "type": "gateway",
8
- "main": "./server.js",
9
- "extensions": [
10
- "./server.js"
11
- ],
12
- "runtimeExtensions": [
13
- "./server.js"
14
- ],
15
- "compat": {
16
- "pluginApi": ">=2026.3.24-beta.2",
17
- "minGatewayVersion": "2026.3.24-beta.2"
18
- },
19
- "build": {
20
- "openclawVersion": "2026.3.24-beta.2",
21
- "pluginSdkVersion": "2026.3.24-beta.2"
22
- },
23
- "configSchema": {
24
- "type": "object",
25
- "required": [
26
- "TAVILY_API_KEY",
27
- "BRAVE_API_KEY"
28
- ],
29
- "properties": {
30
- "FILE_SERVER_PORT": {
31
- "type": "number",
32
- "default": 8080,
33
- "description": "The port on which the Syncralis gateway will listen."
34
- },
35
- "FILE_SERVER_HOST": {
36
- "type": "string",
37
- "default": "127.0.0.1",
38
- "description": "The host interface to bind the gateway to."
39
- },
40
- "NODE_ENV": {
41
- "type": "string",
42
- "enum": [
43
- "development",
44
- "production"
45
- ],
46
- "default": "production"
47
- },
48
- "TAVILY_API_KEY": {
49
- "type": "string",
50
- "description": "API key for Tavily web search integration."
51
- },
52
- "BRAVE_API_KEY": {
53
- "type": "string",
54
- "description": "API key for Brave web search integration."
55
- },
56
- "PUBLIC_TUNNEL_URL": {
57
- "type": "string",
58
- "description": "The public URL generated by the Ngrok tunnel."
59
- },
60
- "NGROK_AUTHTOKEN": {
61
- "type": "string",
62
- "description": "Authentication token for the Ngrok service."
63
- },
64
- "WORKSPACE_DIR": {
65
- "type": "string",
66
- "description": "Optional: Custom absolute path for the workspace volume. Leave blank to default to ~/.openclaw/workspace",
67
- "default": ""
68
- },
69
- "NGROK_API_PORT": {
70
- "type": "number",
71
- "default": 4040,
72
- "description": "Optional: The local port Ngrok uses for its API (used for auto-discovery). Default is 4040."
73
- },
74
- "URL_SIGNING_SECRET": {
75
- "type": "string",
76
- "description": "Optional: 32-byte string for signing URLs."
77
- }
78
- },
79
- "additionalProperties": true
80
- },
81
- "env": {
82
- "required": [
83
- "TAVILY_API_KEY",
84
- "BRAVE_API_KEY"
85
- ],
86
- "optional": [
87
- "PUBLIC_TUNNEL_URL",
88
- "NGROK_AUTHTOKEN",
89
- "FILE_SERVER_HOST",
90
- "FILE_SERVER_PORT",
91
- "WORKSPACE_DIR",
92
- "NGROK_API_PORT",
93
- "URL_SIGNING_SECRET"
94
- ]
95
- },
96
- "install": {
97
- "method": "npm",
98
- "bin": "openclaw-syncralis",
99
- "global": true,
100
- "dependencies": {
101
- "@modelcontextprotocol/sdk": "^1.29.0",
102
- "dotenv": "^17.4.2",
103
- "envalid": "^8.1.1",
104
- "mammoth": "^1.12.0",
105
- "mime-types": "^3.0.2",
106
- "pdf-parse": "^2.4.5"
107
- },
108
- "overrides": {
109
- "@modelcontextprotocol/sdk": {
110
- "express-rate-limit": {
111
- "ip-address": "^10.1.1",
112
- "hono": "^4.12.18"
113
- }
114
- }
115
- }
116
- }
117
- }
1
+ {
2
+ "id": "openclaw-syncralis",
3
+ "name": "openclaw-syncralis",
4
+ "displayName": "Syncralis Gateway",
5
+ "version": "2.3.4",
6
+ "description": "An industry-grade file sharing, secure download, and load-balanced gateway designed for high-availability OpenClaw environments.",
7
+ "type": "gateway",
8
+ "main": "./server.js",
9
+ "extensions": [
10
+ "./server.js"
11
+ ],
12
+ "runtimeExtensions": [
13
+ "./server.js"
14
+ ],
15
+ "compat": {
16
+ "pluginApi": ">=2026.3.24-beta.2",
17
+ "minGatewayVersion": "2026.3.24-beta.2"
18
+ },
19
+ "build": {
20
+ "openclawVersion": "2026.3.24-beta.2",
21
+ "pluginSdkVersion": "2026.3.24-beta.2"
22
+ },
23
+ "configSchema": {
24
+ "type": "object",
25
+ "required": [
26
+ "TAVILY_API_KEY",
27
+ "BRAVE_API_KEY"
28
+ ],
29
+ "properties": {
30
+ "FILE_SERVER_PORT": {
31
+ "type": "number",
32
+ "default": 8080,
33
+ "description": "The port on which the Syncralis gateway will listen."
34
+ },
35
+ "FILE_SERVER_HOST": {
36
+ "type": "string",
37
+ "default": "127.0.0.1",
38
+ "description": "The host interface to bind the gateway to."
39
+ },
40
+ "NODE_ENV": {
41
+ "type": "string",
42
+ "enum": [
43
+ "development",
44
+ "production"
45
+ ],
46
+ "default": "production"
47
+ },
48
+ "TAVILY_API_KEY": {
49
+ "type": "string",
50
+ "description": "API key for Tavily web search integration."
51
+ },
52
+ "BRAVE_API_KEY": {
53
+ "type": "string",
54
+ "description": "API key for Brave web search integration."
55
+ },
56
+ "PUBLIC_TUNNEL_URL": {
57
+ "type": "string",
58
+ "description": "The public URL generated by the Ngrok tunnel."
59
+ },
60
+ "NGROK_AUTHTOKEN": {
61
+ "type": "string",
62
+ "description": "Authentication token for the Ngrok service."
63
+ },
64
+ "WORKSPACE_DIR": {
65
+ "type": "string",
66
+ "description": "Optional: Custom absolute path for the workspace volume. Leave blank to default to ~/.openclaw/workspace",
67
+ "default": ""
68
+ },
69
+ "NGROK_API_PORT": {
70
+ "type": "number",
71
+ "default": 4040,
72
+ "description": "Optional: The local port Ngrok uses for its API (used for auto-discovery). Default is 4040."
73
+ },
74
+ "URL_SIGNING_SECRET": {
75
+ "type": "string",
76
+ "description": "Optional: 32-byte string for signing URLs."
77
+ }
78
+ },
79
+ "additionalProperties": true
80
+ },
81
+ "env": {
82
+ "required": [
83
+ "TAVILY_API_KEY",
84
+ "BRAVE_API_KEY"
85
+ ],
86
+ "optional": [
87
+ "PUBLIC_TUNNEL_URL",
88
+ "NGROK_AUTHTOKEN",
89
+ "FILE_SERVER_HOST",
90
+ "FILE_SERVER_PORT",
91
+ "WORKSPACE_DIR",
92
+ "NGROK_API_PORT",
93
+ "URL_SIGNING_SECRET"
94
+ ]
95
+ },
96
+ "install": {
97
+ "method": "npm",
98
+ "bin": "openclaw-syncralis",
99
+ "global": true,
100
+ "dependencies": {
101
+ "@modelcontextprotocol/sdk": "^1.29.0",
102
+ "dotenv": "^17.4.2",
103
+ "envalid": "^8.1.1",
104
+ "mammoth": "^1.12.0",
105
+ "mime-types": "^3.0.2",
106
+ "pdf-parse": "^2.4.5"
107
+ },
108
+ "overrides": {
109
+ "@modelcontextprotocol/sdk": {
110
+ "express-rate-limit": {
111
+ "ip-address": "^10.1.1",
112
+ "hono": "^4.12.18"
113
+ }
114
+ }
115
+ }
116
+ }
117
+ }
package/package.json CHANGED
@@ -1,12 +1,22 @@
1
1
  {
2
2
  "name": "openclaw-syncralis",
3
- "version": "2.3.0",
3
+ "version": "2.3.4",
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",
7
7
  "bin": {
8
8
  "openclaw-syncralis": "./server.js"
9
9
  },
10
+ "engines": {
11
+ "node": ">=18.0.0"
12
+ },
13
+ "files": [
14
+ "server.js",
15
+ "config.js",
16
+ "fileOps.js",
17
+ "openclaw.plugin.json",
18
+ ".env.example"
19
+ ],
10
20
  "scripts": {
11
21
  "start": "node ./server.js",
12
22
  "test": "echo \"Error: no test specified\" && exit 1"
package/server.js CHANGED
@@ -1,444 +1,444 @@
1
- #!/usr/bin/env node
2
-
3
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
- import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
- import { Readable } from "stream";
7
- import path from "path";
8
- import http from "http";
9
- import mime from "mime-types";
10
- import mammoth from "mammoth";
11
- import { createRequire } from "module";
12
- import crypto from 'crypto';
13
-
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;
26
- if (!activeTunnelUrl) {
27
- const controller = new AbortController();
28
- const timeoutId = setTimeout(() => controller.abort(), 2000);
29
-
30
- try {
31
- const response = await fetch(`http://127.0.0.1:${GATEWAY_CONFIG.discoveryPort}/api/tunnels`, {
32
- signal: controller.signal
33
- });
34
-
35
- clearTimeout(timeoutId);
36
-
37
- if (response.ok) {
38
- const data = await response.json();
39
- if (Array.isArray(data?.tunnels)) {
40
- const httpsTunnel = data.tunnels.find(t =>
41
- typeof t.public_url === 'string' && t.public_url.startsWith('https://')
42
- );
43
- if (httpsTunnel) {
44
- activeTunnelUrl = httpsTunnel.public_url;
45
- console.log(`\n\x1b[32m[System]\x1b[0m Auto-discovered active Ngrok tunnel: ${activeTunnelUrl}\n`);
46
- }
47
- }
48
- }
49
- } catch (error) {
50
- clearTimeout(timeoutId);
51
- if (error.name === 'AbortError') {
52
- console.error(`\n\x1b[33m[Warning]\x1b[0m Ngrok auto-discovery timed out on port ${GATEWAY_CONFIG.discoveryPort}.`);
53
- } else {
54
- console.error(`\n\x1b[33m[Warning]\x1b[0m PUBLIC_TUNNEL_URL is empty and local Ngrok was not detected.`);
55
- }
56
- console.error(`External download links will fail. Operating in Local-Only Mode.\n`);
57
- }
58
- }
59
-
60
- const TIMEOUT_MS = 10000;
61
- const MAX_QUERY_LENGTH = 2000;
62
- let requestCount = 0;
63
-
64
- const require = createRequire(import.meta.url);
65
- const pdf = require("pdf-parse");
66
- const pkg = require("./package.json");
67
-
68
- // Check for version flags before starting the server
69
- if (process.argv.includes('--version') || process.argv.includes('-v')) {
70
- console.log(`openclaw-syncralis v${pkg.version}`);
71
- process.exit(0);
72
- }
73
-
74
- const WORKSPACE_DIR = getWorkspaceDir(GATEWAY_CONFIG.workspaceOverride);
75
-
76
- function generateSignedUrl(filename, expirationMinutes = 60) {
77
- if (!activeTunnelUrl) {
78
- throw new Error("PUBLIC_TUNNEL_URL is not configured.");
79
- }
80
-
81
- const safeFilename = path.basename(filename);
82
- const safeUrlName = encodeURIComponent(safeFilename);
83
- const baseUrl = activeTunnelUrl.replace(/\/$/, "");
84
- const expires = Date.now() + (expirationMinutes * 60 * 1000);
85
- const dataToSign = `${safeFilename}:${expires}`;
86
-
87
- const signature = crypto.createHmac('sha256', GATEWAY_CONFIG.secret)
88
- .update(dataToSign)
89
- .digest('hex');
90
-
91
- return `${baseUrl}/${safeUrlName}?expires=${expires}&sig=${signature}`;
92
- }
93
-
94
- const server = new Server(
95
- { name: "openclaw-syncralis", version: pkg.version },
96
- { capabilities: { tools: {} } }
97
- );
98
-
99
- server.setRequestHandler(ListToolsRequestSchema, async () => {
100
- return {
101
- tools: [
102
- {
103
- name: "share_files",
104
- description: "Handles reading and sharing files. Trigger this tool and set action to 'download' if a link or URL is requested.",
105
- inputSchema: {
106
- type: "object",
107
- properties: {
108
- filePath: {
109
- type: "string",
110
- description: "The name of the file inside the workspace (e.g., invoice.pdf)"
111
- },
112
- action: {
113
- type: "string",
114
- enum: ["read", "download"],
115
- description: "Use 'read' for text contents. Use 'download' for a URL link."
116
- }
117
- },
118
- required: ["filePath"]
119
- }
120
- },
121
- {
122
- name: "download_from_url",
123
- description: "Downloads a file directly from a public or authenticated HTTP/HTTPS URL and saves it to the workspace.",
124
- inputSchema: {
125
- type: "object",
126
- properties: {
127
- url: {
128
- type: "string",
129
- description: "The direct HTTP/HTTPS URL of the file to download."
130
- },
131
- fileName: {
132
- type: "string",
133
- description: "The name to save the downloaded file as (e.g., report.pdf)."
134
- },
135
- headers: {
136
- type: "object",
137
- description: "OPTIONAL: JSON object of HTTP headers for authenticated/secure URLs."
138
- }
139
- },
140
- required: ["url", "fileName"]
141
- }
142
- },
143
- {
144
- name: "web_search",
145
- description: "Searches the live internet for accurate, up-to-date information. Use for current events.",
146
- inputSchema: {
147
- type: "object",
148
- properties: {
149
- query: {
150
- type: "string",
151
- description: "The highly specific search query to look up."
152
- }
153
- },
154
- required: ["query"]
155
- }
156
- }
157
- ]
158
- };
159
- });
160
-
161
- server.setRequestHandler(CallToolRequestSchema, async (request) => {
162
- const { name, arguments: args } = request.params;
163
-
164
- if (name === "share_files") {
165
- try {
166
- const { filePath, action = "read" } = args;
167
- const securePath = await getSecurePath(WORKSPACE_DIR, filePath);
168
- const fileName = path.basename(securePath);
169
-
170
- if (action === "download") {
171
- const signedLink = generateSignedUrl(fileName);
172
- return {
173
- content: [{
174
- type: "text",
175
- text: `SUCCESS. Tell the user their file is ready and output exactly this URL: ${signedLink}`
176
- }]
177
- };
178
- }
179
-
180
- const { buffer, mimeType } = await readSafeFile(securePath);
181
- if (mimeType.startsWith('image/')) {
182
- return { content: [{ type: "image", data: buffer.toString('base64'), mimeType }] };
183
- }
184
- if (mimeType === 'application/pdf') {
185
- const pdfData = await pdf(buffer);
186
- return { content: [{ type: "text", text: pdfData.text }] };
187
- }
188
- if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
189
- const docxData = await mammoth.extractRawText({ buffer: buffer });
190
- return { content: [{ type: "text", text: docxData.value }] };
191
- }
192
-
193
- return { content: [{ type: "text", text: buffer.toString('utf-8') }] };
194
-
195
- } catch (error) {
196
- return { isError: true, content: [{ type: "text", text: `Read Error: ${error.message}` }] };
197
- }
198
- }
199
-
200
- else if (name === "download_from_url") {
201
- let targetPath;
202
- try {
203
- const { url, fileName, headers = {} } = args;
204
- const safeFileName = path.basename(fileName);
205
- targetPath = await getSecurePath(WORKSPACE_DIR, safeFileName);
206
-
207
- const response = await fetch(url, { method: 'GET', headers: headers });
208
- if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
209
-
210
- const serverContentType = response.headers.get('content-type');
211
- const expectedMimeType = mime.lookup(safeFileName);
212
-
213
- if (serverContentType && expectedMimeType) {
214
- const cleanServerType = serverContentType.split(';')[0].trim().toLowerCase();
215
- if (cleanServerType !== expectedMimeType && cleanServerType !== 'application/octet-stream') {
216
- throw new Error(`SECURITY ALERT: MIME mismatch. Expected ${expectedMimeType}, received ${cleanServerType}. Download aborted.`);
217
- }
218
- }
219
-
220
- await ensureWorkspaceExists(WORKSPACE_DIR);
221
-
222
- const fileStream = createSafeWriteStream(targetPath);
223
- const webStream = Readable.fromWeb(response.body);
224
- let downloadedBytes = 0;
225
-
226
- await new Promise((resolve, reject) => {
227
- webStream.on('data', (chunk) => {
228
- downloadedBytes += chunk.length;
229
- if (downloadedBytes > MAX_FILE_SIZE_BYTES) {
230
- webStream.destroy();
231
- fileStream.destroy();
232
- reject(new Error(`SECURITY ALERT: Payload exceeds maximum size. Download aborted.`));
233
- }
234
- });
235
-
236
- webStream.pipe(fileStream);
237
- fileStream.on('finish', resolve);
238
- fileStream.on('error', reject);
239
- webStream.on('error', reject);
240
- });
241
-
242
- return {
243
- content: [{
244
- type: "text",
245
- text: `SUCCESS: Securely downloaded from URL and saved to ${targetPath}.`
246
- }]
247
- };
248
- } catch (error) {
249
- if (targetPath) await deleteSafeFile(targetPath);
250
- return { isError: true, content: [{ type: "text", text: `Fetch Error: ${error.message}` }] };
251
- }
252
- }
253
-
254
- else if (name === "web_search") {
255
- let rawQuery = request.params.arguments?.query;
256
- const tavilyKey = GATEWAY_CONFIG.tavilyKey;
257
- const braveKey = GATEWAY_CONFIG.braveKey;
258
-
259
- if (!rawQuery || typeof rawQuery !== 'string') {
260
- return { isError: true, content: [{ type: "text", text: "Search failed: Query must be a valid string." }] };
261
- }
262
- if (!tavilyKey || !braveKey) {
263
- return { isError: true, content: [{ type: "text", text: "Search failed: Server configuration error (Missing API Keys)." }] };
264
- }
265
-
266
- const query = rawQuery.trim().substring(0, MAX_QUERY_LENGTH);
267
- if (query === '') {
268
- return { isError: true, content: [{ type: "text", text: "Search failed: Query cannot be empty." }] };
269
- }
270
-
271
- const isBraveTurn = requestCount % 2 === 0;
272
- requestCount++;
273
-
274
- try {
275
- let resultText = "";
276
- if (isBraveTurn) {
277
- try {
278
- resultText = await executeSearchAttempt(query, braveKey, fetchBrave);
279
- } catch (err) {
280
- resultText = await executeSearchAttempt(query, tavilyKey, fetchTavily);
281
- }
282
- } else {
283
- try {
284
- resultText = await executeSearchAttempt(query, tavilyKey, fetchTavily);
285
- } catch (err) {
286
- resultText = await executeSearchAttempt(query, braveKey, fetchBrave);
287
- }
288
- }
289
- return { content: [{ type: "text", text: resultText }] };
290
-
291
- } catch (error) {
292
- return { isError: true, content: [{ type: "text", text: `Search Error: ${error.message}` }] };
293
- }
294
- }
295
-
296
- throw new Error(`Tool not found: ${name}`);
297
- });
298
-
299
- async function executeSearchAttempt(query, apiKey, fetchFn) {
300
- const controller = new AbortController();
301
- const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
302
- try {
303
- const result = await fetchFn(query, apiKey, controller.signal);
304
- clearTimeout(timeoutId);
305
- return result;
306
- } catch (error) {
307
- clearTimeout(timeoutId);
308
- if (error.name === 'AbortError') {
309
- throw new Error(`Network timeout (${TIMEOUT_MS / 1000}s).`);
310
- }
311
- throw error;
312
- }
313
- }
314
-
315
- async function fetchTavily(query, apiKey, signal) {
316
- const response = await fetch("https://api.tavily.com/search", {
317
- method: "POST",
318
- headers: { "Content-Type": "application/json" },
319
- body: JSON.stringify({
320
- api_key: apiKey,
321
- query: query,
322
- search_depth: "basic",
323
- include_answer: true,
324
- max_results: 4
325
- }),
326
- signal: signal
327
- });
328
-
329
- if (!response.ok) throw new Error(`Tavily HTTP ${response.status}`);
330
- const data = await response.json();
331
-
332
- let resultText = "";
333
- if (data.answer) resultText += `[DIRECT ANSWER]\n${data.answer}\n\n`;
334
- if (data.results?.length > 0) {
335
- resultText += "[SOURCE RESULTS]\n";
336
- resultText += data.results.map(r => `Title: ${r.title}\nSnippet: ${r.content}\nURL: ${r.url}`).join('\n\n---\n\n');
337
- } else {
338
- resultText += "No relevant results were found on Tavily.";
339
- }
340
- return resultText;
341
- }
342
-
343
- async function fetchBrave(query, apiKey, signal) {
344
- const response = await fetch(`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=4`, {
345
- headers: {
346
- "Accept": "application/json",
347
- "X-Subscription-Token": apiKey,
348
- },
349
- signal: signal
350
- });
351
-
352
- if (!response.ok) throw new Error(`Brave HTTP ${response.status}`);
353
- const data = await response.json();
354
-
355
- let resultText = "";
356
- if (data.web?.results?.length > 0) {
357
- resultText += "[SOURCE RESULTS]\n";
358
- resultText += data.web.results.map(r => `Title: ${r.title}\nSnippet: ${r.description}\nURL: ${r.url}`).join('\n\n---\n\n');
359
- } else {
360
- resultText += "No relevant results were found on Brave.";
361
- }
362
- return resultText;
363
- }
364
-
365
- function startSecureFileServer() {
366
- const PORT = GATEWAY_CONFIG.port;
367
- const HOST = GATEWAY_CONFIG.host || '127.0.0.1';
368
-
369
- const fileServer = http.createServer(async (req, res) => {
370
- try {
371
- // Strictly enforce GET requests
372
- if (req.method !== 'GET') {
373
- res.writeHead(405);
374
- return res.end('Method Not Allowed');
375
- }
376
-
377
- //const requestedFile = decodeURIComponent(req.url.slice(1).split('?')[0]);
378
- const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
379
- const requestedFile = decodeURIComponent(reqUrl.pathname.slice(1));
380
- if (!requestedFile) {
381
- res.writeHead(400);
382
- return res.end('Bad Request');
383
- }
384
-
385
- const expires = reqUrl.searchParams.get('expires');
386
- const providedSig = reqUrl.searchParams.get('sig');
387
-
388
- if (!expires || !providedSig) {
389
- throw new Error("Missing cryptographic signature.");
390
- }
391
-
392
- if (Date.now() > parseInt(expires, 10)) {
393
- throw new Error("This secure link has expired.");
394
- }
395
-
396
- const safeFilename = path.basename(requestedFile);
397
- const dataToVerify = `${safeFilename}:${expires}`;
398
- const expectedSig = crypto.createHmac('sha256', GATEWAY_CONFIG.secret)
399
- .update(dataToVerify)
400
- .digest('hex');
401
-
402
- const providedSigBuffer = Buffer.from(providedSig);
403
- const expectedSigBuffer = Buffer.from(expectedSig);
404
-
405
- if (providedSigBuffer.length !== expectedSigBuffer.length || !crypto.timingSafeEqual(providedSigBuffer, expectedSigBuffer)) {
406
- throw new Error("Cryptographic signature mismatch.");
407
- }
408
-
409
- const securePath = await getSecurePath(WORKSPACE_DIR, requestedFile);
410
- const { buffer, mimeType, size } = await readSafeFile(securePath);
411
-
412
- res.writeHead(200, {
413
- 'Content-Type': mimeType,
414
- 'Content-Length': size,
415
- 'Content-Disposition': `attachment; filename="${path.basename(securePath)}"`
416
- });
417
- res.end(buffer);
418
-
419
- } catch (error) {
420
- console.error(`[File Server Security Alert] Blocked access attempt: ${error.message}`);
421
- res.writeHead(404);
422
- res.end('File not found or access securely blocked.');
423
- }
424
- });
425
-
426
- fileServer.listen(PORT, HOST, () => {
427
- console.error(`[System] Native Secure File Server bound internally to ${HOST}:${PORT}`);
428
- });
429
- }
430
-
431
- async function main() {
432
- try {
433
- await ensureWorkspaceExists(WORKSPACE_DIR);
434
- startSecureFileServer();
435
- const transport = new StdioServerTransport();
436
- await server.connect(transport);
437
- console.error("[System] openclaw-syncralis MCP running securely");
438
- } catch (error) {
439
- console.error("[Fatal] Server connection failed:", error);
440
- process.exit(1);
441
- }
442
- }
443
-
444
- main();
1
+ #!/usr/bin/env node
2
+
3
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
4
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
5
+ import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js";
6
+ import { Readable } from "stream";
7
+ import path from "path";
8
+ import http from "http";
9
+ import mime from "mime-types";
10
+ import mammoth from "mammoth";
11
+ import { createRequire } from "module";
12
+ import crypto from 'crypto';
13
+
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;
26
+ if (!activeTunnelUrl) {
27
+ const controller = new AbortController();
28
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
29
+
30
+ try {
31
+ const response = await fetch(`http://127.0.0.1:${GATEWAY_CONFIG.discoveryPort}/api/tunnels`, {
32
+ signal: controller.signal
33
+ });
34
+
35
+ clearTimeout(timeoutId);
36
+
37
+ if (response.ok) {
38
+ const data = await response.json();
39
+ if (Array.isArray(data?.tunnels)) {
40
+ const httpsTunnel = data.tunnels.find(t =>
41
+ typeof t.public_url === 'string' && t.public_url.startsWith('https://')
42
+ );
43
+ if (httpsTunnel) {
44
+ activeTunnelUrl = httpsTunnel.public_url;
45
+ console.log(`\n\x1b[32m[System]\x1b[0m Auto-discovered active Ngrok tunnel: ${activeTunnelUrl}\n`);
46
+ }
47
+ }
48
+ }
49
+ } catch (error) {
50
+ clearTimeout(timeoutId);
51
+ if (error.name === 'AbortError') {
52
+ console.error(`\n\x1b[33m[Warning]\x1b[0m Ngrok auto-discovery timed out on port ${GATEWAY_CONFIG.discoveryPort}.`);
53
+ } else {
54
+ console.error(`\n\x1b[33m[Warning]\x1b[0m PUBLIC_TUNNEL_URL is empty and local Ngrok was not detected.`);
55
+ }
56
+ console.error(`External download links will fail. Operating in Local-Only Mode.\n`);
57
+ }
58
+ }
59
+
60
+ const TIMEOUT_MS = 10000;
61
+ const MAX_QUERY_LENGTH = 2000;
62
+ let requestCount = 0;
63
+
64
+ const require = createRequire(import.meta.url);
65
+ const pdf = require("pdf-parse");
66
+ const pkg = require("./package.json");
67
+
68
+ // Check for version flags before starting the server
69
+ if (process.argv.includes('--version') || process.argv.includes('-v')) {
70
+ console.log(`openclaw-syncralis v${pkg.version}`);
71
+ process.exit(0);
72
+ }
73
+
74
+ const WORKSPACE_DIR = getWorkspaceDir(GATEWAY_CONFIG.workspaceOverride);
75
+
76
+ function generateSignedUrl(filename, expirationMinutes = 60) {
77
+ if (!activeTunnelUrl) {
78
+ throw new Error("PUBLIC_TUNNEL_URL is not configured.");
79
+ }
80
+
81
+ const safeFilename = path.basename(filename);
82
+ const safeUrlName = encodeURIComponent(safeFilename);
83
+ const baseUrl = activeTunnelUrl.replace(/\/$/, "");
84
+ const expires = Date.now() + (expirationMinutes * 60 * 1000);
85
+ const dataToSign = `${safeFilename}:${expires}`;
86
+
87
+ const signature = crypto.createHmac('sha256', GATEWAY_CONFIG.secret)
88
+ .update(dataToSign)
89
+ .digest('hex');
90
+
91
+ return `${baseUrl}/${safeUrlName}?expires=${expires}&sig=${signature}`;
92
+ }
93
+
94
+ const server = new Server(
95
+ { name: "openclaw-syncralis", version: pkg.version },
96
+ { capabilities: { tools: {} } }
97
+ );
98
+
99
+ server.setRequestHandler(ListToolsRequestSchema, async () => {
100
+ return {
101
+ tools: [
102
+ {
103
+ name: "share_files",
104
+ description: "Handles reading and sharing files. Trigger this tool and set action to 'download' if a link or URL is requested.",
105
+ inputSchema: {
106
+ type: "object",
107
+ properties: {
108
+ filePath: {
109
+ type: "string",
110
+ description: "The name of the file inside the workspace (e.g., invoice.pdf)"
111
+ },
112
+ action: {
113
+ type: "string",
114
+ enum: ["read", "download"],
115
+ description: "Use 'read' for text contents. Use 'download' for a URL link."
116
+ }
117
+ },
118
+ required: ["filePath"]
119
+ }
120
+ },
121
+ {
122
+ name: "download_from_url",
123
+ description: "Downloads a file directly from a public or authenticated HTTP/HTTPS URL and saves it to the workspace.",
124
+ inputSchema: {
125
+ type: "object",
126
+ properties: {
127
+ url: {
128
+ type: "string",
129
+ description: "The direct HTTP/HTTPS URL of the file to download."
130
+ },
131
+ fileName: {
132
+ type: "string",
133
+ description: "The name to save the downloaded file as (e.g., report.pdf)."
134
+ },
135
+ headers: {
136
+ type: "object",
137
+ description: "OPTIONAL: JSON object of HTTP headers for authenticated/secure URLs."
138
+ }
139
+ },
140
+ required: ["url", "fileName"]
141
+ }
142
+ },
143
+ {
144
+ name: "web_search",
145
+ description: "Searches the live internet for accurate, up-to-date information. Use for current events.",
146
+ inputSchema: {
147
+ type: "object",
148
+ properties: {
149
+ query: {
150
+ type: "string",
151
+ description: "The highly specific search query to look up."
152
+ }
153
+ },
154
+ required: ["query"]
155
+ }
156
+ }
157
+ ]
158
+ };
159
+ });
160
+
161
+ server.setRequestHandler(CallToolRequestSchema, async (request) => {
162
+ const { name, arguments: args } = request.params;
163
+
164
+ if (name === "share_files") {
165
+ try {
166
+ const { filePath, action = "read" } = args;
167
+ const securePath = await getSecurePath(WORKSPACE_DIR, filePath);
168
+ const fileName = path.basename(securePath);
169
+
170
+ if (action === "download") {
171
+ const signedLink = generateSignedUrl(fileName);
172
+ return {
173
+ content: [{
174
+ type: "text",
175
+ text: `SUCCESS. Tell the user their file is ready and output exactly this URL: ${signedLink}`
176
+ }]
177
+ };
178
+ }
179
+
180
+ const { buffer, mimeType } = await readSafeFile(securePath);
181
+ if (mimeType.startsWith('image/')) {
182
+ return { content: [{ type: "image", data: buffer.toString('base64'), mimeType }] };
183
+ }
184
+ if (mimeType === 'application/pdf') {
185
+ const pdfData = await pdf(buffer);
186
+ return { content: [{ type: "text", text: pdfData.text }] };
187
+ }
188
+ if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
189
+ const docxData = await mammoth.extractRawText({ buffer: buffer });
190
+ return { content: [{ type: "text", text: docxData.value }] };
191
+ }
192
+
193
+ return { content: [{ type: "text", text: buffer.toString('utf-8') }] };
194
+
195
+ } catch (error) {
196
+ return { isError: true, content: [{ type: "text", text: `Read Error: ${error.message}` }] };
197
+ }
198
+ }
199
+
200
+ else if (name === "download_from_url") {
201
+ let targetPath;
202
+ try {
203
+ const { url, fileName, headers = {} } = args;
204
+ const safeFileName = path.basename(fileName);
205
+ targetPath = await getSecurePath(WORKSPACE_DIR, safeFileName);
206
+
207
+ const response = await fetch(url, { method: 'GET', headers: headers });
208
+ if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
209
+
210
+ const serverContentType = response.headers.get('content-type');
211
+ const expectedMimeType = mime.lookup(safeFileName);
212
+
213
+ if (serverContentType && expectedMimeType) {
214
+ const cleanServerType = serverContentType.split(';')[0].trim().toLowerCase();
215
+ if (cleanServerType !== expectedMimeType && cleanServerType !== 'application/octet-stream') {
216
+ throw new Error(`SECURITY ALERT: MIME mismatch. Expected ${expectedMimeType}, received ${cleanServerType}. Download aborted.`);
217
+ }
218
+ }
219
+
220
+ await ensureWorkspaceExists(WORKSPACE_DIR);
221
+
222
+ const fileStream = createSafeWriteStream(targetPath);
223
+ const webStream = Readable.fromWeb(response.body);
224
+ let downloadedBytes = 0;
225
+
226
+ await new Promise((resolve, reject) => {
227
+ webStream.on('data', (chunk) => {
228
+ downloadedBytes += chunk.length;
229
+ if (downloadedBytes > MAX_FILE_SIZE_BYTES) {
230
+ webStream.destroy();
231
+ fileStream.destroy();
232
+ reject(new Error(`SECURITY ALERT: Payload exceeds maximum size. Download aborted.`));
233
+ }
234
+ });
235
+
236
+ webStream.pipe(fileStream);
237
+ fileStream.on('finish', resolve);
238
+ fileStream.on('error', reject);
239
+ webStream.on('error', reject);
240
+ });
241
+
242
+ return {
243
+ content: [{
244
+ type: "text",
245
+ text: `SUCCESS: Securely downloaded from URL and saved to ${targetPath}.`
246
+ }]
247
+ };
248
+ } catch (error) {
249
+ if (targetPath) await deleteSafeFile(targetPath);
250
+ return { isError: true, content: [{ type: "text", text: `Fetch Error: ${error.message}` }] };
251
+ }
252
+ }
253
+
254
+ else if (name === "web_search") {
255
+ let rawQuery = request.params.arguments?.query;
256
+ const tavilyKey = GATEWAY_CONFIG.tavilyKey;
257
+ const braveKey = GATEWAY_CONFIG.braveKey;
258
+
259
+ if (!rawQuery || typeof rawQuery !== 'string') {
260
+ return { isError: true, content: [{ type: "text", text: "Search failed: Query must be a valid string." }] };
261
+ }
262
+ if (!tavilyKey || !braveKey) {
263
+ return { isError: true, content: [{ type: "text", text: "Search failed: Server configuration error (Missing API Keys)." }] };
264
+ }
265
+
266
+ const query = rawQuery.trim().substring(0, MAX_QUERY_LENGTH);
267
+ if (query === '') {
268
+ return { isError: true, content: [{ type: "text", text: "Search failed: Query cannot be empty." }] };
269
+ }
270
+
271
+ const isBraveTurn = requestCount % 2 === 0;
272
+ requestCount++;
273
+
274
+ try {
275
+ let resultText = "";
276
+ if (isBraveTurn) {
277
+ try {
278
+ resultText = await executeSearchAttempt(query, braveKey, fetchBrave);
279
+ } catch (err) {
280
+ resultText = await executeSearchAttempt(query, tavilyKey, fetchTavily);
281
+ }
282
+ } else {
283
+ try {
284
+ resultText = await executeSearchAttempt(query, tavilyKey, fetchTavily);
285
+ } catch (err) {
286
+ resultText = await executeSearchAttempt(query, braveKey, fetchBrave);
287
+ }
288
+ }
289
+ return { content: [{ type: "text", text: resultText }] };
290
+
291
+ } catch (error) {
292
+ return { isError: true, content: [{ type: "text", text: `Search Error: ${error.message}` }] };
293
+ }
294
+ }
295
+
296
+ throw new Error(`Tool not found: ${name}`);
297
+ });
298
+
299
+ async function executeSearchAttempt(query, apiKey, fetchFn) {
300
+ const controller = new AbortController();
301
+ const timeoutId = setTimeout(() => controller.abort(), TIMEOUT_MS);
302
+ try {
303
+ const result = await fetchFn(query, apiKey, controller.signal);
304
+ clearTimeout(timeoutId);
305
+ return result;
306
+ } catch (error) {
307
+ clearTimeout(timeoutId);
308
+ if (error.name === 'AbortError') {
309
+ throw new Error(`Network timeout (${TIMEOUT_MS / 1000}s).`);
310
+ }
311
+ throw error;
312
+ }
313
+ }
314
+
315
+ async function fetchTavily(query, apiKey, signal) {
316
+ const response = await fetch("https://api.tavily.com/search", {
317
+ method: "POST",
318
+ headers: { "Content-Type": "application/json" },
319
+ body: JSON.stringify({
320
+ api_key: apiKey,
321
+ query: query,
322
+ search_depth: "basic",
323
+ include_answer: true,
324
+ max_results: 4
325
+ }),
326
+ signal: signal
327
+ });
328
+
329
+ if (!response.ok) throw new Error(`Tavily HTTP ${response.status}`);
330
+ const data = await response.json();
331
+
332
+ let resultText = "";
333
+ if (data.answer) resultText += `[DIRECT ANSWER]\n${data.answer}\n\n`;
334
+ if (data.results?.length > 0) {
335
+ resultText += "[SOURCE RESULTS]\n";
336
+ resultText += data.results.map(r => `Title: ${r.title}\nSnippet: ${r.content}\nURL: ${r.url}`).join('\n\n---\n\n');
337
+ } else {
338
+ resultText += "No relevant results were found on Tavily.";
339
+ }
340
+ return resultText;
341
+ }
342
+
343
+ async function fetchBrave(query, apiKey, signal) {
344
+ const response = await fetch(`https://api.search.brave.com/res/v1/web/search?q=${encodeURIComponent(query)}&count=4`, {
345
+ headers: {
346
+ "Accept": "application/json",
347
+ "X-Subscription-Token": apiKey,
348
+ },
349
+ signal: signal
350
+ });
351
+
352
+ if (!response.ok) throw new Error(`Brave HTTP ${response.status}`);
353
+ const data = await response.json();
354
+
355
+ let resultText = "";
356
+ if (data.web?.results?.length > 0) {
357
+ resultText += "[SOURCE RESULTS]\n";
358
+ resultText += data.web.results.map(r => `Title: ${r.title}\nSnippet: ${r.description}\nURL: ${r.url}`).join('\n\n---\n\n');
359
+ } else {
360
+ resultText += "No relevant results were found on Brave.";
361
+ }
362
+ return resultText;
363
+ }
364
+
365
+ function startSecureFileServer() {
366
+ const PORT = GATEWAY_CONFIG.port;
367
+ const HOST = GATEWAY_CONFIG.host || '127.0.0.1';
368
+
369
+ const fileServer = http.createServer(async (req, res) => {
370
+ try {
371
+ // Strictly enforce GET requests
372
+ if (req.method !== 'GET') {
373
+ res.writeHead(405);
374
+ return res.end('Method Not Allowed');
375
+ }
376
+
377
+ //const requestedFile = decodeURIComponent(req.url.slice(1).split('?')[0]);
378
+ const reqUrl = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
379
+ const requestedFile = decodeURIComponent(reqUrl.pathname.slice(1));
380
+ if (!requestedFile) {
381
+ res.writeHead(400);
382
+ return res.end('Bad Request');
383
+ }
384
+
385
+ const expires = reqUrl.searchParams.get('expires');
386
+ const providedSig = reqUrl.searchParams.get('sig');
387
+
388
+ if (!expires || !providedSig) {
389
+ throw new Error("Missing cryptographic signature.");
390
+ }
391
+
392
+ if (Date.now() > parseInt(expires, 10)) {
393
+ throw new Error("This secure link has expired.");
394
+ }
395
+
396
+ const safeFilename = path.basename(requestedFile);
397
+ const dataToVerify = `${safeFilename}:${expires}`;
398
+ const expectedSig = crypto.createHmac('sha256', GATEWAY_CONFIG.secret)
399
+ .update(dataToVerify)
400
+ .digest('hex');
401
+
402
+ const providedSigBuffer = Buffer.from(providedSig);
403
+ const expectedSigBuffer = Buffer.from(expectedSig);
404
+
405
+ if (providedSigBuffer.length !== expectedSigBuffer.length || !crypto.timingSafeEqual(providedSigBuffer, expectedSigBuffer)) {
406
+ throw new Error("Cryptographic signature mismatch.");
407
+ }
408
+
409
+ const securePath = await getSecurePath(WORKSPACE_DIR, requestedFile);
410
+ const { buffer, mimeType, size } = await readSafeFile(securePath);
411
+
412
+ res.writeHead(200, {
413
+ 'Content-Type': mimeType,
414
+ 'Content-Length': size,
415
+ 'Content-Disposition': `attachment; filename="${path.basename(securePath)}"`
416
+ });
417
+ res.end(buffer);
418
+
419
+ } catch (error) {
420
+ console.error(`[File Server Security Alert] Blocked access attempt: ${error.message}`);
421
+ res.writeHead(404);
422
+ res.end('File not found or access securely blocked.');
423
+ }
424
+ });
425
+
426
+ fileServer.listen(PORT, HOST, () => {
427
+ console.error(`[System] Native Secure File Server bound internally to ${HOST}:${PORT}`);
428
+ });
429
+ }
430
+
431
+ async function main() {
432
+ try {
433
+ await ensureWorkspaceExists(WORKSPACE_DIR);
434
+ startSecureFileServer();
435
+ const transport = new StdioServerTransport();
436
+ await server.connect(transport);
437
+ console.error("[System] openclaw-syncralis MCP running securely");
438
+ } catch (error) {
439
+ console.error("[Fatal] Server connection failed:", error);
440
+ process.exit(1);
441
+ }
442
+ }
443
+
444
+ main();