openclaw-syncralis 2.1.1 → 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/.env.example +2 -0
- package/README.md +27 -4
- package/config.js +41 -0
- package/fileOps.js +46 -0
- package/openclaw.plugin.json +24 -6
- package/package.json +24 -6
- package/server.js +70 -67
package/.env.example
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
WORKSPACE_DIR=""
|
|
1
2
|
PUBLIC_TUNNEL_URL="https://your-domain.ngrok-free.app"
|
|
2
3
|
NGROK_AUTHTOKEN=your_ngrok_authtoken_here
|
|
4
|
+
NGROK_API_PORT=4040
|
|
3
5
|
URL_SIGNING_SECRET="your_custom_32_character_secret_here"
|
|
4
6
|
TAVILY_API_KEY=your_tavily_key_here
|
|
5
7
|
BRAVE_API_KEY=your_brave_key_here
|
package/README.md
CHANGED
|
@@ -59,8 +59,17 @@ clawhub login pslkk/openclaw-syncralis
|
|
|
59
59
|
Syncralis is designed as a hybrid tool. It works perfectly on your native operating system (Windows/Mac/Linux) or securely inside a Dockerized environment.
|
|
60
60
|
|
|
61
61
|
|
|
62
|
-
|
|
62
|
+
#### The Workspace Directory (`WORKSPACE_DIR`):
|
|
63
63
|
|
|
64
|
+
The gateway needs a secure folder to store and manage files. We have designed this to be fully automated, but flexible for power users:
|
|
65
|
+
|
|
66
|
+
***Native / Default Install (Recommended):** Leave `WORKSPACE_DIR=` completely empty (or omit it). The gateway will automatically detect your OS and securely store files in your native home directory: `~/.openclaw/workspace`.
|
|
67
|
+
|
|
68
|
+
***Docker / Custom Environments:** If you are running OpenClaw inside a custom Docker container or want to force the gateway to use a specific volume mount, define the absolute path here:
|
|
69
|
+
`WORKSPACE_DIR=/custom/path/to/workspace`
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
### Choose the deployment method that matches your OpenClaw setup below:
|
|
64
73
|
|
|
65
74
|
### Option 1: Native NPM Setup (Without Docker)
|
|
66
75
|
|
|
@@ -69,8 +78,17 @@ When running OpenClaw natively on your host machine, Syncralis spins up a secure
|
|
|
69
78
|
|
|
70
79
|
1. Open a new terminal window and run Ngrok to expose the default port:
|
|
71
80
|
|
|
81
|
+
*Note: For a production setup, we highly recommend using your static URL from the Ngrok dashboard so your tunnel never changes.*
|
|
82
|
+
|
|
72
83
|
```bash
|
|
73
84
|
|
|
85
|
+
# 1. Authenticate your terminal (Run this once)
|
|
86
|
+
ngrok config add-authtoken your_ngrok_token_here
|
|
87
|
+
|
|
88
|
+
# 2. Start the tunnel using your static URL (Recommended)
|
|
89
|
+
ngrok http --url your-custom-url.ngrok-free.app 8080
|
|
90
|
+
|
|
91
|
+
# Or, using a dynamic URL (Testing only)
|
|
74
92
|
ngrok http 8080
|
|
75
93
|
|
|
76
94
|
```
|
|
@@ -85,8 +103,10 @@ When running OpenClaw natively on your host machine, Syncralis spins up a secure
|
|
|
85
103
|
"command": "openclaw-syncralis",
|
|
86
104
|
"env": {
|
|
87
105
|
"NODE_ENV": "production",
|
|
88
|
-
"FILE_SERVER_HOST": "127.0.0.1",
|
|
106
|
+
"FILE_SERVER_HOST": "127.0.0.1",
|
|
107
|
+
"WORKSPACE_DIR": "",
|
|
89
108
|
"PUBLIC_TUNNEL_URL": "https://your-ngrok-url.ngrok-free.app",
|
|
109
|
+
"NGROK_API_PORT": 4040,
|
|
90
110
|
"URL_SIGNING_SECRET": "your_custom_32_character_secret_here",
|
|
91
111
|
"TAVILY_API_KEY": "your_tavily_key",
|
|
92
112
|
"BRAVE_API_KEY": "your_brave_key"
|
|
@@ -114,7 +134,9 @@ OpenClaw often executes tools as ephemeral child processes. In a containerized s
|
|
|
114
134
|
"env": {
|
|
115
135
|
"NODE_ENV": "production",
|
|
116
136
|
"FILE_SERVER_HOST": "0.0.0.0",
|
|
137
|
+
"WORKSPACE_DIR": "",
|
|
117
138
|
"PUBLIC_TUNNEL_URL": "https://your-static-domain.ngrok-free.app",
|
|
139
|
+
"NGROK_API_PORT": 4040,
|
|
118
140
|
"URL_SIGNING_SECRET": "your_custom_32_character_secret_here",
|
|
119
141
|
"TAVILY_API_KEY": "your_tavily_key",
|
|
120
142
|
"BRAVE_API_KEY": "your_brave_key"
|
|
@@ -153,8 +175,9 @@ services:
|
|
|
153
175
|
extra_hosts:
|
|
154
176
|
- "host.docker.internal:host-gateway"
|
|
155
177
|
volumes:
|
|
156
|
-
-
|
|
157
|
-
-
|
|
178
|
+
- ./claw_data:/home/node/.openclaw:rw
|
|
179
|
+
- # Your config file
|
|
180
|
+
- ./workspace:/home/node/.openclaw/workspace:rw
|
|
158
181
|
environment:
|
|
159
182
|
- FILE_SERVER_HOST=0.0.0.0
|
|
160
183
|
- FILE_SERVER_PORT=8080
|
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",
|
|
@@ -22,7 +22,10 @@
|
|
|
22
22
|
},
|
|
23
23
|
"configSchema": {
|
|
24
24
|
"type": "object",
|
|
25
|
-
"required": [
|
|
25
|
+
"required": [
|
|
26
|
+
"TAVILY_API_KEY",
|
|
27
|
+
"BRAVE_API_KEY"
|
|
28
|
+
],
|
|
26
29
|
"properties": {
|
|
27
30
|
"FILE_SERVER_PORT": {
|
|
28
31
|
"type": "number",
|
|
@@ -58,6 +61,16 @@
|
|
|
58
61
|
"type": "string",
|
|
59
62
|
"description": "Authentication token for the Ngrok service."
|
|
60
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
|
+
},
|
|
61
74
|
"URL_SIGNING_SECRET": {
|
|
62
75
|
"type": "string",
|
|
63
76
|
"description": "Optional: 32-byte string for signing URLs."
|
|
@@ -66,14 +79,17 @@
|
|
|
66
79
|
"additionalProperties": true
|
|
67
80
|
},
|
|
68
81
|
"env": {
|
|
69
|
-
"required": [
|
|
70
|
-
"optional": [
|
|
82
|
+
"required": [
|
|
71
83
|
"TAVILY_API_KEY",
|
|
72
|
-
"BRAVE_API_KEY"
|
|
84
|
+
"BRAVE_API_KEY"
|
|
85
|
+
],
|
|
86
|
+
"optional": [
|
|
73
87
|
"PUBLIC_TUNNEL_URL",
|
|
74
88
|
"NGROK_AUTHTOKEN",
|
|
75
89
|
"FILE_SERVER_HOST",
|
|
76
90
|
"FILE_SERVER_PORT",
|
|
91
|
+
"WORKSPACE_DIR",
|
|
92
|
+
"NGROK_API_PORT",
|
|
77
93
|
"URL_SIGNING_SECRET"
|
|
78
94
|
]
|
|
79
95
|
},
|
|
@@ -84,6 +100,7 @@
|
|
|
84
100
|
"dependencies": {
|
|
85
101
|
"@modelcontextprotocol/sdk": "^1.29.0",
|
|
86
102
|
"dotenv": "^17.4.2",
|
|
103
|
+
"envalid": "^8.1.1",
|
|
87
104
|
"mammoth": "^1.12.0",
|
|
88
105
|
"mime-types": "^3.0.2",
|
|
89
106
|
"pdf-parse": "^2.4.5"
|
|
@@ -91,7 +108,8 @@
|
|
|
91
108
|
"overrides": {
|
|
92
109
|
"@modelcontextprotocol/sdk": {
|
|
93
110
|
"express-rate-limit": {
|
|
94
|
-
"ip-address": "^10.1.1"
|
|
111
|
+
"ip-address": "^10.1.1",
|
|
112
|
+
"hono": "^4.12.18"
|
|
95
113
|
}
|
|
96
114
|
}
|
|
97
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
|
},
|
|
@@ -64,7 +66,10 @@
|
|
|
64
66
|
},
|
|
65
67
|
"configSchema": {
|
|
66
68
|
"type": "object",
|
|
67
|
-
"required": [
|
|
69
|
+
"required": [
|
|
70
|
+
"TAVILY_API_KEY",
|
|
71
|
+
"BRAVE_API_KEY"
|
|
72
|
+
],
|
|
68
73
|
"properties": {
|
|
69
74
|
"FILE_SERVER_PORT": {
|
|
70
75
|
"type": "number",
|
|
@@ -100,6 +105,16 @@
|
|
|
100
105
|
"type": "string",
|
|
101
106
|
"description": "Authentication token for the Ngrok service."
|
|
102
107
|
},
|
|
108
|
+
"WORKSPACE_DIR": {
|
|
109
|
+
"type": "string",
|
|
110
|
+
"description": "Optional: Custom absolute path for the workspace volume. Leave blank to default to ~/.openclaw/workspace",
|
|
111
|
+
"default": ""
|
|
112
|
+
},
|
|
113
|
+
"NGROK_API_PORT": {
|
|
114
|
+
"type": "number",
|
|
115
|
+
"default": 4040,
|
|
116
|
+
"description": "Optional: The local port Ngrok uses for its API (used for auto-discovery). Default is 4040."
|
|
117
|
+
},
|
|
103
118
|
"URL_SIGNING_SECRET": {
|
|
104
119
|
"type": "string",
|
|
105
120
|
"description": "Optional: 32-byte string for signing URLs."
|
|
@@ -108,14 +123,17 @@
|
|
|
108
123
|
"additionalProperties": true
|
|
109
124
|
},
|
|
110
125
|
"env": {
|
|
111
|
-
"required": [
|
|
112
|
-
"optional": [
|
|
126
|
+
"required": [
|
|
113
127
|
"TAVILY_API_KEY",
|
|
114
|
-
"BRAVE_API_KEY"
|
|
128
|
+
"BRAVE_API_KEY"
|
|
129
|
+
],
|
|
130
|
+
"optional": [
|
|
115
131
|
"PUBLIC_TUNNEL_URL",
|
|
116
132
|
"NGROK_AUTHTOKEN",
|
|
117
133
|
"FILE_SERVER_HOST",
|
|
118
134
|
"FILE_SERVER_PORT",
|
|
135
|
+
"WORKSPACE_DIR",
|
|
136
|
+
"NGROK_API_PORT",
|
|
119
137
|
"URL_SIGNING_SECRET"
|
|
120
138
|
]
|
|
121
139
|
}
|
package/server.js
CHANGED
|
@@ -3,32 +3,59 @@
|
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
+
}
|
|
32
59
|
|
|
33
60
|
const TIMEOUT_MS = 10000;
|
|
34
61
|
const MAX_QUERY_LENGTH = 2000;
|
|
@@ -44,42 +71,28 @@ if (process.argv.includes('--version') || process.argv.includes('-v')) {
|
|
|
44
71
|
process.exit(0);
|
|
45
72
|
}
|
|
46
73
|
|
|
47
|
-
const WORKSPACE_DIR =
|
|
48
|
-
const MAX_FILE_SIZE_BYTES = 50 * 1024 * 1024;
|
|
74
|
+
const WORKSPACE_DIR = getWorkspaceDir(GATEWAY_CONFIG.workspaceOverride);
|
|
49
75
|
|
|
50
76
|
function generateSignedUrl(filename, expirationMinutes = 60) {
|
|
51
|
-
if (!
|
|
52
|
-
|
|
77
|
+
if (!activeTunnelUrl) {
|
|
78
|
+
throw new Error("PUBLIC_TUNNEL_URL is not configured.");
|
|
53
79
|
}
|
|
54
80
|
|
|
55
81
|
const safeFilename = path.basename(filename);
|
|
56
82
|
const safeUrlName = encodeURIComponent(safeFilename);
|
|
57
|
-
const baseUrl =
|
|
83
|
+
const baseUrl = activeTunnelUrl.replace(/\/$/, "");
|
|
58
84
|
const expires = Date.now() + (expirationMinutes * 60 * 1000);
|
|
59
85
|
const dataToSign = `${safeFilename}:${expires}`;
|
|
60
86
|
|
|
61
|
-
const signature = crypto.createHmac('sha256', GATEWAY_CONFIG.
|
|
87
|
+
const signature = crypto.createHmac('sha256', GATEWAY_CONFIG.secret)
|
|
62
88
|
.update(dataToSign)
|
|
63
89
|
.digest('hex');
|
|
64
90
|
|
|
65
91
|
return `${baseUrl}/${safeUrlName}?expires=${expires}&sig=${signature}`;
|
|
66
92
|
}
|
|
67
93
|
|
|
68
|
-
async function getSecurePath(requestedPath) {
|
|
69
|
-
const isAbsolutePath = path.isAbsolute(requestedPath);
|
|
70
|
-
const targetPath = isAbsolutePath ? requestedPath : path.join(WORKSPACE_DIR, requestedPath);
|
|
71
|
-
const resolvedPath = path.resolve(targetPath);
|
|
72
|
-
|
|
73
|
-
// Mathematical boundary check to prevent partial directory matching traversal
|
|
74
|
-
const relativePath = path.relative(WORKSPACE_DIR, resolvedPath);
|
|
75
|
-
if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
|
|
76
|
-
throw new Error(`SECURITY ALERT: Path traversal attempt blocked.`);
|
|
77
|
-
}
|
|
78
|
-
return resolvedPath;
|
|
79
|
-
}
|
|
80
|
-
|
|
81
94
|
const server = new Server(
|
|
82
|
-
{ name: "openclaw-syncralis", version:
|
|
95
|
+
{ name: "openclaw-syncralis", version: pkg.version },
|
|
83
96
|
{ capabilities: { tools: {} } }
|
|
84
97
|
);
|
|
85
98
|
|
|
@@ -151,15 +164,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
151
164
|
if (name === "share_files") {
|
|
152
165
|
try {
|
|
153
166
|
const { filePath, action = "read" } = args;
|
|
154
|
-
const securePath = await getSecurePath(filePath);
|
|
167
|
+
const securePath = await getSecurePath(WORKSPACE_DIR, filePath);
|
|
155
168
|
const fileName = path.basename(securePath);
|
|
156
169
|
|
|
157
|
-
const stats = await fsPromises.stat(securePath);
|
|
158
|
-
if (!stats.isFile()) throw new Error(`Requested path is a directory.`);
|
|
159
|
-
if (stats.size > MAX_FILE_SIZE_BYTES) throw new Error(`File exceeds max allowed size.`);
|
|
160
|
-
|
|
161
|
-
const mimeType = mime.lookup(securePath) || 'application/octet-stream';
|
|
162
|
-
|
|
163
170
|
if (action === "download") {
|
|
164
171
|
const signedLink = generateSignedUrl(fileName);
|
|
165
172
|
return {
|
|
@@ -170,20 +177,20 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
170
177
|
};
|
|
171
178
|
}
|
|
172
179
|
|
|
173
|
-
const
|
|
180
|
+
const { buffer, mimeType } = await readSafeFile(securePath);
|
|
174
181
|
if (mimeType.startsWith('image/')) {
|
|
175
|
-
return { content: [{ type: "image", data:
|
|
182
|
+
return { content: [{ type: "image", data: buffer.toString('base64'), mimeType }] };
|
|
176
183
|
}
|
|
177
184
|
if (mimeType === 'application/pdf') {
|
|
178
|
-
const pdfData = await pdf(
|
|
185
|
+
const pdfData = await pdf(buffer);
|
|
179
186
|
return { content: [{ type: "text", text: pdfData.text }] };
|
|
180
187
|
}
|
|
181
188
|
if (mimeType === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document') {
|
|
182
|
-
const docxData = await mammoth.extractRawText({ buffer:
|
|
189
|
+
const docxData = await mammoth.extractRawText({ buffer: buffer });
|
|
183
190
|
return { content: [{ type: "text", text: docxData.value }] };
|
|
184
191
|
}
|
|
185
192
|
|
|
186
|
-
return { content: [{ type: "text", text:
|
|
193
|
+
return { content: [{ type: "text", text: buffer.toString('utf-8') }] };
|
|
187
194
|
|
|
188
195
|
} catch (error) {
|
|
189
196
|
return { isError: true, content: [{ type: "text", text: `Read Error: ${error.message}` }] };
|
|
@@ -195,7 +202,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
195
202
|
try {
|
|
196
203
|
const { url, fileName, headers = {} } = args;
|
|
197
204
|
const safeFileName = path.basename(fileName);
|
|
198
|
-
targetPath =
|
|
205
|
+
targetPath = await getSecurePath(WORKSPACE_DIR, safeFileName);
|
|
199
206
|
|
|
200
207
|
const response = await fetch(url, { method: 'GET', headers: headers });
|
|
201
208
|
if (!response.ok) throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
|
@@ -210,9 +217,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
210
217
|
}
|
|
211
218
|
}
|
|
212
219
|
|
|
213
|
-
await
|
|
214
|
-
|
|
215
|
-
const fileStream =
|
|
220
|
+
await ensureWorkspaceExists(WORKSPACE_DIR);
|
|
221
|
+
|
|
222
|
+
const fileStream = createSafeWriteStream(targetPath);
|
|
216
223
|
const webStream = Readable.fromWeb(response.body);
|
|
217
224
|
let downloadedBytes = 0;
|
|
218
225
|
|
|
@@ -239,7 +246,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
|
239
246
|
}]
|
|
240
247
|
};
|
|
241
248
|
} catch (error) {
|
|
242
|
-
if (targetPath) await
|
|
249
|
+
if (targetPath) await deleteSafeFile(targetPath);
|
|
243
250
|
return { isError: true, content: [{ type: "text", text: `Fetch Error: ${error.message}` }] };
|
|
244
251
|
}
|
|
245
252
|
}
|
|
@@ -357,7 +364,7 @@ async function fetchBrave(query, apiKey, signal) {
|
|
|
357
364
|
|
|
358
365
|
function startSecureFileServer() {
|
|
359
366
|
const PORT = GATEWAY_CONFIG.port;
|
|
360
|
-
const HOST = GATEWAY_CONFIG.host;
|
|
367
|
+
const HOST = GATEWAY_CONFIG.host || '127.0.0.1';
|
|
361
368
|
|
|
362
369
|
const fileServer = http.createServer(async (req, res) => {
|
|
363
370
|
try {
|
|
@@ -388,7 +395,7 @@ function startSecureFileServer() {
|
|
|
388
395
|
|
|
389
396
|
const safeFilename = path.basename(requestedFile);
|
|
390
397
|
const dataToVerify = `${safeFilename}:${expires}`;
|
|
391
|
-
const expectedSig = crypto.createHmac('sha256', GATEWAY_CONFIG.
|
|
398
|
+
const expectedSig = crypto.createHmac('sha256', GATEWAY_CONFIG.secret)
|
|
392
399
|
.update(dataToVerify)
|
|
393
400
|
.digest('hex');
|
|
394
401
|
|
|
@@ -399,20 +406,15 @@ function startSecureFileServer() {
|
|
|
399
406
|
throw new Error("Cryptographic signature mismatch.");
|
|
400
407
|
}
|
|
401
408
|
|
|
402
|
-
const securePath = await getSecurePath(requestedFile);
|
|
403
|
-
|
|
404
|
-
const stats = await fsPromises.stat(securePath);
|
|
405
|
-
if (!stats.isFile()) throw new Error("Requested path is not a valid file");
|
|
406
|
-
|
|
407
|
-
const mimeType = mime.lookup(securePath) || 'application/octet-stream';
|
|
409
|
+
const securePath = await getSecurePath(WORKSPACE_DIR, requestedFile);
|
|
410
|
+
const { buffer, mimeType, size } = await readSafeFile(securePath);
|
|
408
411
|
|
|
409
412
|
res.writeHead(200, {
|
|
410
413
|
'Content-Type': mimeType,
|
|
411
|
-
'Content-Length':
|
|
414
|
+
'Content-Length': size,
|
|
412
415
|
'Content-Disposition': `attachment; filename="${path.basename(securePath)}"`
|
|
413
416
|
});
|
|
414
|
-
|
|
415
|
-
createReadStream(securePath).pipe(res);
|
|
417
|
+
res.end(buffer);
|
|
416
418
|
|
|
417
419
|
} catch (error) {
|
|
418
420
|
console.error(`[File Server Security Alert] Blocked access attempt: ${error.message}`);
|
|
@@ -428,10 +430,11 @@ function startSecureFileServer() {
|
|
|
428
430
|
|
|
429
431
|
async function main() {
|
|
430
432
|
try {
|
|
433
|
+
await ensureWorkspaceExists(WORKSPACE_DIR);
|
|
431
434
|
startSecureFileServer();
|
|
432
435
|
const transport = new StdioServerTransport();
|
|
433
436
|
await server.connect(transport);
|
|
434
|
-
console.error("[System]
|
|
437
|
+
console.error("[System] openclaw-syncralis MCP running securely");
|
|
435
438
|
} catch (error) {
|
|
436
439
|
console.error("[Fatal] Server connection failed:", error);
|
|
437
440
|
process.exit(1);
|