@mooncompany/uplink-chat 0.5.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.
Potentially problematic release.
This version of @mooncompany/uplink-chat might be problematic. Click here for more details.
- package/LICENSE +21 -0
- package/README.md +185 -0
- package/bin/uplink.js +279 -0
- package/middleware/error-handler.js +69 -0
- package/package.json +93 -0
- package/public/css/agents.36b98c0f.css +1469 -0
- package/public/css/agents.css +1469 -0
- package/public/css/app.a6a7f8f5.css +2731 -0
- package/public/css/app.css +2731 -0
- package/public/css/artifacts.css +444 -0
- package/public/css/commands.css +55 -0
- package/public/css/connection.css +131 -0
- package/public/css/dashboard.css +233 -0
- package/public/css/developer.css +328 -0
- package/public/css/files.css +123 -0
- package/public/css/markdown.css +156 -0
- package/public/css/message-actions.css +278 -0
- package/public/css/mobile.css +614 -0
- package/public/css/panels-unified.css +483 -0
- package/public/css/premium.css +415 -0
- package/public/css/realtime.css +189 -0
- package/public/css/satellites.css +401 -0
- package/public/css/shortcuts.css +185 -0
- package/public/css/split-view.4def0262.css +673 -0
- package/public/css/split-view.css +673 -0
- package/public/css/theme-generator.css +391 -0
- package/public/css/themes.css +387 -0
- package/public/css/timestamps.css +54 -0
- package/public/css/variables.css +78 -0
- package/public/dist/bundle.b55050c4.js +15757 -0
- package/public/favicon.svg +24 -0
- package/public/img/agents/ada.png +0 -0
- package/public/img/agents/clarice.png +0 -0
- package/public/img/agents/dennis-nedry.png +0 -0
- package/public/img/agents/elliot-alderson.png +0 -0
- package/public/img/agents/main.png +0 -0
- package/public/img/agents/scotty.png +0 -0
- package/public/img/agents/top-flight-security.png +0 -0
- package/public/index.html +1083 -0
- package/public/js/agents-data.js +234 -0
- package/public/js/agents-ui.js +72 -0
- package/public/js/agents.js +1525 -0
- package/public/js/app.js +79 -0
- package/public/js/appearance-settings.js +111 -0
- package/public/js/artifacts.js +432 -0
- package/public/js/audio-queue.js +168 -0
- package/public/js/bootstrap.js +54 -0
- package/public/js/chat.js +1211 -0
- package/public/js/commands.js +581 -0
- package/public/js/connection-api.js +121 -0
- package/public/js/connection.js +1231 -0
- package/public/js/context-tracker.js +271 -0
- package/public/js/core.js +172 -0
- package/public/js/dashboard.js +452 -0
- package/public/js/developer.js +432 -0
- package/public/js/encryption.js +124 -0
- package/public/js/errors.js +122 -0
- package/public/js/event-bus.js +77 -0
- package/public/js/fetch-utils.js +171 -0
- package/public/js/file-handler.js +229 -0
- package/public/js/files.js +352 -0
- package/public/js/gateway-chat.js +538 -0
- package/public/js/logger.js +112 -0
- package/public/js/markdown.js +190 -0
- package/public/js/message-actions.js +431 -0
- package/public/js/message-renderer.js +288 -0
- package/public/js/missed-messages.js +235 -0
- package/public/js/mobile-debug.js +95 -0
- package/public/js/notifications.js +367 -0
- package/public/js/offline-queue.js +178 -0
- package/public/js/onboarding.js +543 -0
- package/public/js/panels.js +156 -0
- package/public/js/premium.js +412 -0
- package/public/js/realtime-voice.js +844 -0
- package/public/js/satellite-sync.js +256 -0
- package/public/js/satellite-ui.js +175 -0
- package/public/js/satellites.js +1516 -0
- package/public/js/settings.js +1087 -0
- package/public/js/shortcuts.js +381 -0
- package/public/js/split-chat.js +1234 -0
- package/public/js/split-resize.js +211 -0
- package/public/js/splitview.js +340 -0
- package/public/js/storage.js +408 -0
- package/public/js/streaming-handler.js +324 -0
- package/public/js/stt-settings.js +316 -0
- package/public/js/theme-generator.js +661 -0
- package/public/js/themes.js +164 -0
- package/public/js/timestamps.js +198 -0
- package/public/js/tts-settings.js +575 -0
- package/public/js/ui.js +267 -0
- package/public/js/update-notifier.js +143 -0
- package/public/js/utils/constants.js +165 -0
- package/public/js/utils/sanitize.js +93 -0
- package/public/js/utils/sse-parser.js +195 -0
- package/public/js/voice.js +883 -0
- package/public/manifest.json +58 -0
- package/public/moon_texture.jpg +0 -0
- package/public/sw.js +221 -0
- package/public/three.min.js +6 -0
- package/server/channel.js +529 -0
- package/server/chat.js +270 -0
- package/server/config-store.js +362 -0
- package/server/config.js +159 -0
- package/server/context.js +131 -0
- package/server/gateway-commands.js +211 -0
- package/server/gateway-proxy.js +318 -0
- package/server/index.js +22 -0
- package/server/logger.js +89 -0
- package/server/middleware/auth.js +188 -0
- package/server/middleware.js +218 -0
- package/server/openclaw-discover.js +308 -0
- package/server/premium/index.js +156 -0
- package/server/premium/license.js +140 -0
- package/server/realtime/bridge.js +837 -0
- package/server/realtime/index.js +349 -0
- package/server/realtime/tts-stream.js +446 -0
- package/server/routes/agents.js +564 -0
- package/server/routes/artifacts.js +174 -0
- package/server/routes/chat.js +311 -0
- package/server/routes/config-settings.js +345 -0
- package/server/routes/config.js +603 -0
- package/server/routes/files.js +307 -0
- package/server/routes/index.js +18 -0
- package/server/routes/media.js +451 -0
- package/server/routes/missed-messages.js +107 -0
- package/server/routes/premium.js +75 -0
- package/server/routes/push.js +156 -0
- package/server/routes/satellite.js +406 -0
- package/server/routes/status.js +251 -0
- package/server/routes/stt.js +35 -0
- package/server/routes/voice.js +260 -0
- package/server/routes/webhooks.js +203 -0
- package/server/routes.js +206 -0
- package/server/runtime-config.js +336 -0
- package/server/share.js +305 -0
- package/server/stt/faster-whisper.js +72 -0
- package/server/stt/groq.js +51 -0
- package/server/stt/index.js +196 -0
- package/server/stt/openai.js +49 -0
- package/server/sync.js +244 -0
- package/server/tailscale-https.js +175 -0
- package/server/tts.js +646 -0
- package/server/update-checker.js +172 -0
- package/server/utils/filename.js +129 -0
- package/server/utils.js +147 -0
- package/server/watchdog.js +318 -0
- package/server/websocket/broadcast.js +359 -0
- package/server/websocket/connections.js +339 -0
- package/server/websocket/index.js +215 -0
- package/server/websocket/routing.js +277 -0
- package/server/websocket/sync.js +102 -0
- package/server.js +404 -0
- package/utils/detect-tool-usage.js +93 -0
- package/utils/errors.js +158 -0
- package/utils/html-escape.js +84 -0
- package/utils/id-sanitize.js +94 -0
- package/utils/response.js +130 -0
- package/utils/with-retry.js +105 -0
|
@@ -0,0 +1,218 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Middleware Module - Security headers, CSRF, rate limiting
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import rateLimit from 'express-rate-limit';
|
|
6
|
+
import cors from 'cors';
|
|
7
|
+
import { timingSafeEqual } from 'crypto';
|
|
8
|
+
import { ALLOWED_ORIGINS, WEBHOOK_TOKEN, GATEWAY_TOKEN as STATIC_GATEWAY_TOKEN } from './config.js';
|
|
9
|
+
import { isPrivateIP } from './utils.js';
|
|
10
|
+
import { loadConfig } from './runtime-config.js';
|
|
11
|
+
import { createLogger } from './logger.js';
|
|
12
|
+
|
|
13
|
+
const log = createLogger('middleware');
|
|
14
|
+
|
|
15
|
+
// Re-export isPrivateIP for backward compatibility
|
|
16
|
+
export { isPrivateIP };
|
|
17
|
+
|
|
18
|
+
// ===========================================
|
|
19
|
+
// Rate Limiting
|
|
20
|
+
// ===========================================
|
|
21
|
+
// Skip rate limiting for localhost/loopback — only limit external IPs
|
|
22
|
+
const isLoopback = (ip) => ip === '127.0.0.1' || ip === '::1' || ip === '::ffff:127.0.0.1';
|
|
23
|
+
|
|
24
|
+
export const apiLimiter = rateLimit({
|
|
25
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
26
|
+
max: 500, // Limit each IP to 500 requests per window (increased for active chatting)
|
|
27
|
+
message: { error: true, message: 'Too many requests, please try again later.', code: 'RATE_LIMITED' },
|
|
28
|
+
standardHeaders: true,
|
|
29
|
+
legacyHeaders: false,
|
|
30
|
+
skip: (req) => isLoopback(req.ip),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
export const strictLimiter = rateLimit({
|
|
34
|
+
windowMs: 15 * 60 * 1000, // 15 minutes
|
|
35
|
+
max: 30, // Limit each IP to 30 requests per window (was 10, too aggressive for onboarding)
|
|
36
|
+
message: { error: true, message: 'Too many requests, please try again later.', code: 'RATE_LIMITED' },
|
|
37
|
+
standardHeaders: true,
|
|
38
|
+
legacyHeaders: false,
|
|
39
|
+
skip: (req) => isLoopback(req.ip),
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// ===========================================
|
|
43
|
+
// SSRF Prevention
|
|
44
|
+
// ===========================================
|
|
45
|
+
// isPrivateIP is now consolidated in server/utils.js - import from there
|
|
46
|
+
|
|
47
|
+
// ===========================================
|
|
48
|
+
// CSRF Protection
|
|
49
|
+
// ===========================================
|
|
50
|
+
export function csrfProtection(req, res, next) {
|
|
51
|
+
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
|
|
52
|
+
return next();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const origin = req.get('Origin');
|
|
56
|
+
const referer = req.get('Referer');
|
|
57
|
+
const host = req.get('Host');
|
|
58
|
+
|
|
59
|
+
if (!origin && !referer) {
|
|
60
|
+
return next();
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (origin) {
|
|
64
|
+
try {
|
|
65
|
+
const originUrl = new URL(origin);
|
|
66
|
+
const hostWithoutPort = host.split(':')[0];
|
|
67
|
+
if (originUrl.hostname !== hostWithoutPort &&
|
|
68
|
+
originUrl.hostname !== 'localhost' &&
|
|
69
|
+
originUrl.hostname !== '127.0.0.1' &&
|
|
70
|
+
!originUrl.hostname.endsWith('.ts.net')) {
|
|
71
|
+
log.warn(`CSRF blocked: origin ${origin} doesn't match host ${host}`);
|
|
72
|
+
return res.status(403).json({ error: 'CSRF validation failed' });
|
|
73
|
+
}
|
|
74
|
+
} catch (e) {
|
|
75
|
+
return res.status(403).json({ error: 'Invalid origin header' });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
next();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ===========================================
|
|
83
|
+
// Security Headers
|
|
84
|
+
// ===========================================
|
|
85
|
+
export function securityHeaders(req, res, next) {
|
|
86
|
+
const nonce = res.locals.nonce;
|
|
87
|
+
|
|
88
|
+
// Allow embedding from localhost and production RedOS
|
|
89
|
+
// Using frame-ancestors instead of X-Frame-Options for more control
|
|
90
|
+
const allowedFrameAncestors = [
|
|
91
|
+
"'self'",
|
|
92
|
+
"http://localhost:3000", // RedOS dev server
|
|
93
|
+
"http://127.0.0.1:3000",
|
|
94
|
+
"https://redos.moonco.pro", // RedOS production
|
|
95
|
+
].join(' ');
|
|
96
|
+
|
|
97
|
+
res.setHeader('X-Content-Type-Options', 'nosniff');
|
|
98
|
+
res.setHeader('X-XSS-Protection', '1; mode=block');
|
|
99
|
+
res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');
|
|
100
|
+
|
|
101
|
+
// Build CSP with nonce if available
|
|
102
|
+
const scriptSrc = nonce
|
|
103
|
+
? `script-src 'self' 'nonce-${nonce}' 'unsafe-hashes' https://cdnjs.cloudflare.com`
|
|
104
|
+
: `script-src 'self' 'unsafe-hashes' https://cdnjs.cloudflare.com`;
|
|
105
|
+
|
|
106
|
+
const cspDirectives = [
|
|
107
|
+
"default-src 'self'",
|
|
108
|
+
scriptSrc,
|
|
109
|
+
"style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
|
|
110
|
+
"font-src 'self' https://fonts.gstatic.com",
|
|
111
|
+
"img-src 'self' data: blob:",
|
|
112
|
+
"connect-src 'self' data: blob: ws: wss: https://api.elevenlabs.io https://fonts.googleapis.com https://fonts.gstatic.com https://static.cloudflareinsights.com",
|
|
113
|
+
"media-src 'self' blob:",
|
|
114
|
+
`frame-ancestors ${allowedFrameAncestors}`
|
|
115
|
+
];
|
|
116
|
+
|
|
117
|
+
res.setHeader('Content-Security-Policy', cspDirectives.join('; '));
|
|
118
|
+
next();
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ===========================================
|
|
122
|
+
// CORS Configuration
|
|
123
|
+
// ===========================================
|
|
124
|
+
export const corsMiddleware = cors({
|
|
125
|
+
origin: (origin, callback) => {
|
|
126
|
+
if (!origin) return callback(null, true);
|
|
127
|
+
if (ALLOWED_ORIGINS.includes(origin)) {
|
|
128
|
+
return callback(null, true);
|
|
129
|
+
}
|
|
130
|
+
// Auto-allow Tailscale HTTPS origins (*.ts.net)
|
|
131
|
+
try {
|
|
132
|
+
const url = new URL(origin);
|
|
133
|
+
if (url.hostname.endsWith('.ts.net')) {
|
|
134
|
+
return callback(null, true);
|
|
135
|
+
}
|
|
136
|
+
} catch {}
|
|
137
|
+
callback(new Error('CORS not allowed'));
|
|
138
|
+
},
|
|
139
|
+
methods: ['GET', 'POST', 'DELETE'], // M-25: Added DELETE for cross-origin delete routes
|
|
140
|
+
credentials: true,
|
|
141
|
+
maxAge: 86400
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
// ===========================================
|
|
145
|
+
// Bearer Token Verification (Sync & API auth)
|
|
146
|
+
// ===========================================
|
|
147
|
+
export async function verifyBearerToken(req, res, next) {
|
|
148
|
+
// Get token dynamically (includes auto-discovered values from OpenClaw config)
|
|
149
|
+
let expectedToken = STATIC_GATEWAY_TOKEN;
|
|
150
|
+
if (!expectedToken) {
|
|
151
|
+
try {
|
|
152
|
+
const config = await loadConfig();
|
|
153
|
+
expectedToken = config.gatewayToken;
|
|
154
|
+
} catch {
|
|
155
|
+
// Fall through to the check below
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (!expectedToken) {
|
|
160
|
+
return res.status(503).json({ error: true, message: 'Authentication not configured', code: 'AUTH_NOT_CONFIGURED' });
|
|
161
|
+
}
|
|
162
|
+
const authHeader = req.headers.authorization || '';
|
|
163
|
+
const token = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : '';
|
|
164
|
+
if (!token) {
|
|
165
|
+
return res.status(401).json({ error: true, message: 'Missing Authorization header', code: 'UNAUTHORIZED' });
|
|
166
|
+
}
|
|
167
|
+
const providedBuf = Buffer.from(token);
|
|
168
|
+
const expectedBuf = Buffer.from(expectedToken);
|
|
169
|
+
if (providedBuf.length !== expectedBuf.length || !timingSafeEqual(providedBuf, expectedBuf)) {
|
|
170
|
+
return res.status(401).json({ error: true, message: 'Invalid token', code: 'INVALID_TOKEN' });
|
|
171
|
+
}
|
|
172
|
+
next();
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// ===========================================
|
|
176
|
+
// Webhook Token Verification
|
|
177
|
+
// ===========================================
|
|
178
|
+
export function verifyWebhookToken(req, res, next) {
|
|
179
|
+
if (!WEBHOOK_TOKEN) {
|
|
180
|
+
return res.status(503).json({
|
|
181
|
+
error: 'Webhooks disabled. Set WEBHOOK_TOKEN in .env to enable.'
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
const token = req.headers['x-webhook-token'] || req.query.token || '';
|
|
185
|
+
|
|
186
|
+
// Timing-safe comparison to prevent timing attacks
|
|
187
|
+
const providedBuf = Buffer.from(token);
|
|
188
|
+
const expectedBuf = Buffer.from(WEBHOOK_TOKEN);
|
|
189
|
+
if (providedBuf.length !== expectedBuf.length) {
|
|
190
|
+
return res.status(401).json({ error: 'Invalid webhook token' });
|
|
191
|
+
}
|
|
192
|
+
if (!timingSafeEqual(providedBuf, expectedBuf)) {
|
|
193
|
+
return res.status(401).json({ error: 'Invalid webhook token' });
|
|
194
|
+
}
|
|
195
|
+
next();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// ===========================================
|
|
199
|
+
// UTF-8 JSON Response
|
|
200
|
+
// ===========================================
|
|
201
|
+
export function jsonUtf8(req, res, next) {
|
|
202
|
+
const originalJson = res.json.bind(res);
|
|
203
|
+
res.json = (data) => {
|
|
204
|
+
res.setHeader('Content-Type', 'application/json; charset=utf-8');
|
|
205
|
+
return originalJson(data);
|
|
206
|
+
};
|
|
207
|
+
next();
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// ===========================================
|
|
211
|
+
// No Cache Headers
|
|
212
|
+
// ===========================================
|
|
213
|
+
export function noCache(req, res, next) {
|
|
214
|
+
res.set('Cache-Control', 'no-store, no-cache, must-revalidate, proxy-revalidate');
|
|
215
|
+
res.set('Pragma', 'no-cache');
|
|
216
|
+
res.set('Expires', '0');
|
|
217
|
+
next();
|
|
218
|
+
}
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OpenClaw Gateway Auto-Discovery
|
|
3
|
+
*
|
|
4
|
+
* Reads the local OpenClaw config to auto-detect gateway URL and token.
|
|
5
|
+
* Eliminates manual onboarding for same-machine installs.
|
|
6
|
+
*
|
|
7
|
+
* Discovery order:
|
|
8
|
+
* 1. Environment variables (GATEWAY_URL, GATEWAY_TOKEN) — highest priority
|
|
9
|
+
* 2. OPENCLAW_STATE_DIR env var (explicit path override)
|
|
10
|
+
* 3. Native OS config (~/.openclaw/openclaw.json)
|
|
11
|
+
* 4. WSL config (Windows only — checks \\wsl.localhost\<distro>\home\<user>\.openclaw\)
|
|
12
|
+
* 5. Default fallback (http://127.0.0.1:18789, no token)
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import fs from 'fs/promises';
|
|
16
|
+
import path from 'path';
|
|
17
|
+
import os from 'os';
|
|
18
|
+
import { execSync } from 'child_process';
|
|
19
|
+
import { createLogger } from './logger.js';
|
|
20
|
+
|
|
21
|
+
const log = createLogger('Discovery');
|
|
22
|
+
|
|
23
|
+
// OpenClaw config locations (in priority order)
|
|
24
|
+
const OPENCLAW_STATE_DIR = process.env.OPENCLAW_STATE_DIR || path.join(os.homedir(), '.openclaw');
|
|
25
|
+
const OPENCLAW_CONFIG_PATH = path.join(OPENCLAW_STATE_DIR, 'openclaw.json');
|
|
26
|
+
|
|
27
|
+
// Default OpenClaw gateway port
|
|
28
|
+
const DEFAULT_GATEWAY_PORT = 18789;
|
|
29
|
+
|
|
30
|
+
// WSL distros to skip (not real Linux installs)
|
|
31
|
+
const WSL_SKIP_DISTROS = new Set(['docker-desktop', 'docker-desktop-data']);
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Attempt to read and parse an OpenClaw config at a given path
|
|
35
|
+
* @param {string} configPath - Path to openclaw.json
|
|
36
|
+
* @returns {{ config: Object, path: string }|null} Parsed config + path, or null
|
|
37
|
+
*/
|
|
38
|
+
async function tryReadConfig(configPath) {
|
|
39
|
+
try {
|
|
40
|
+
const data = await fs.readFile(configPath, 'utf8');
|
|
41
|
+
return { config: JSON.parse(data), path: configPath };
|
|
42
|
+
} catch (err) {
|
|
43
|
+
if (err.code !== 'ENOENT') {
|
|
44
|
+
log.warn(`Failed to read config at ${configPath}:`, err.message);
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Get list of WSL distro names (Windows only)
|
|
52
|
+
* @returns {string[]} Distro names
|
|
53
|
+
*/
|
|
54
|
+
function getWSLDistros() {
|
|
55
|
+
if (os.platform() !== 'win32') return [];
|
|
56
|
+
|
|
57
|
+
try {
|
|
58
|
+
// wsl --list --quiet outputs distro names, one per line
|
|
59
|
+
// Output is UTF-16LE on some Windows versions, handle encoding
|
|
60
|
+
const output = execSync('wsl --list --quiet', {
|
|
61
|
+
encoding: 'utf8',
|
|
62
|
+
timeout: 5000,
|
|
63
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
return output
|
|
67
|
+
.replace(/\0/g, '') // Strip null bytes from UTF-16LE artifacts
|
|
68
|
+
.split(/\r?\n/)
|
|
69
|
+
.map(line => line.trim())
|
|
70
|
+
.filter(line => line && !WSL_SKIP_DISTROS.has(line.toLowerCase()));
|
|
71
|
+
} catch {
|
|
72
|
+
return [];
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Get the WSL user's home directory for a given distro
|
|
78
|
+
* @param {string} distro - WSL distro name
|
|
79
|
+
* @returns {string|null} Home directory path or null
|
|
80
|
+
*/
|
|
81
|
+
function getWSLHomePath(distro) {
|
|
82
|
+
try {
|
|
83
|
+
const output = execSync(`wsl -d ${distro} -- echo $HOME`, {
|
|
84
|
+
encoding: 'utf8',
|
|
85
|
+
timeout: 5000,
|
|
86
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
87
|
+
});
|
|
88
|
+
const home = output.trim().replace(/\0/g, '');
|
|
89
|
+
if (!home) return null;
|
|
90
|
+
|
|
91
|
+
// Convert /home/user to \\wsl.localhost\distro\home\user
|
|
92
|
+
// Try both \\wsl.localhost\ (Win11+) and \\wsl$\ (older)
|
|
93
|
+
return { wslPath: home, distro };
|
|
94
|
+
} catch {
|
|
95
|
+
return null;
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Build Windows UNC paths to access a WSL file
|
|
101
|
+
* @param {string} distro - WSL distro name
|
|
102
|
+
* @param {string} wslPath - Linux path (e.g., /home/user)
|
|
103
|
+
* @returns {string[]} Array of UNC paths to try
|
|
104
|
+
*/
|
|
105
|
+
function buildWSLUNCPaths(distro, wslPath) {
|
|
106
|
+
// Convert forward slashes to backslashes for Windows UNC
|
|
107
|
+
const winSubPath = wslPath.replace(/\//g, '\\');
|
|
108
|
+
return [
|
|
109
|
+
`\\\\wsl.localhost\\${distro}${winSubPath}`, // Win11+ preferred
|
|
110
|
+
`\\\\wsl$\\${distro}${winSubPath}`, // Older Windows 10
|
|
111
|
+
];
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Search for OpenClaw config in WSL distros
|
|
116
|
+
* @returns {{ config: Object, path: string, source: string }|null}
|
|
117
|
+
*/
|
|
118
|
+
async function findWSLConfig() {
|
|
119
|
+
const distros = getWSLDistros();
|
|
120
|
+
if (distros.length === 0) return null;
|
|
121
|
+
|
|
122
|
+
log.debug(`Found WSL distros: ${distros.join(', ')}`);
|
|
123
|
+
|
|
124
|
+
for (const distro of distros) {
|
|
125
|
+
const homeInfo = getWSLHomePath(distro);
|
|
126
|
+
if (!homeInfo) continue;
|
|
127
|
+
|
|
128
|
+
const openclawWslPath = `${homeInfo.wslPath}/.openclaw/openclaw.json`;
|
|
129
|
+
const uncPaths = buildWSLUNCPaths(distro, openclawWslPath);
|
|
130
|
+
|
|
131
|
+
for (const uncPath of uncPaths) {
|
|
132
|
+
const result = await tryReadConfig(uncPath);
|
|
133
|
+
if (result) {
|
|
134
|
+
log.info(`Found OpenClaw config in WSL (${distro}) at ${uncPath}`);
|
|
135
|
+
return { ...result, source: `wsl:${distro}` };
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Read the OpenClaw config, searching native paths first, then WSL
|
|
145
|
+
* @returns {{ config: Object, path: string, source: string }|null}
|
|
146
|
+
*/
|
|
147
|
+
async function readOpenClawConfig() {
|
|
148
|
+
// 1. Try native path (or OPENCLAW_STATE_DIR override)
|
|
149
|
+
const nativeResult = await tryReadConfig(OPENCLAW_CONFIG_PATH);
|
|
150
|
+
if (nativeResult) {
|
|
151
|
+
return { ...nativeResult, source: 'native' };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// 2. On Windows, try WSL paths
|
|
155
|
+
if (os.platform() === 'win32') {
|
|
156
|
+
const wslResult = await findWSLConfig();
|
|
157
|
+
if (wslResult) return wslResult;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
return null;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Extract gateway connection details from OpenClaw config
|
|
165
|
+
* @param {Object} config - Parsed openclaw.json
|
|
166
|
+
* @returns {{ url: string, token: string|null, source: string }}
|
|
167
|
+
*/
|
|
168
|
+
function extractGatewayDetails(config) {
|
|
169
|
+
const gateway = config?.gateway || {};
|
|
170
|
+
const port = gateway.port || DEFAULT_GATEWAY_PORT;
|
|
171
|
+
const url = `http://127.0.0.1:${port}`;
|
|
172
|
+
|
|
173
|
+
// Extract auth token
|
|
174
|
+
let token = null;
|
|
175
|
+
if (gateway.auth) {
|
|
176
|
+
if (gateway.auth.mode === 'token' && gateway.auth.token) {
|
|
177
|
+
token = gateway.auth.token;
|
|
178
|
+
} else if (gateway.auth.mode === 'password' && gateway.auth.password) {
|
|
179
|
+
token = gateway.auth.password;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
return { url, token };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Verify gateway is reachable at the given URL
|
|
188
|
+
* @param {string} url - Gateway URL to check
|
|
189
|
+
* @param {string|null} token - Auth token (optional)
|
|
190
|
+
* @returns {Promise<boolean>} True if gateway responds
|
|
191
|
+
*/
|
|
192
|
+
async function verifyGateway(url, token) {
|
|
193
|
+
try {
|
|
194
|
+
const headers = {};
|
|
195
|
+
if (token) {
|
|
196
|
+
headers['Authorization'] = `Bearer ${token}`;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
const controller = new AbortController();
|
|
200
|
+
const timeout = setTimeout(() => controller.abort(), 3000);
|
|
201
|
+
|
|
202
|
+
const response = await fetch(`${url}/health`, {
|
|
203
|
+
method: 'GET',
|
|
204
|
+
headers,
|
|
205
|
+
signal: controller.signal,
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
clearTimeout(timeout);
|
|
209
|
+
return response.ok;
|
|
210
|
+
} catch {
|
|
211
|
+
return false;
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Auto-discover the OpenClaw gateway
|
|
217
|
+
*
|
|
218
|
+
* Returns discovered gateway details, or null if nothing found.
|
|
219
|
+
* Does NOT override environment variables — those always take priority.
|
|
220
|
+
*
|
|
221
|
+
* @returns {Promise<{ url: string, token: string|null, source: string, verified: boolean }|null>}
|
|
222
|
+
*/
|
|
223
|
+
export async function discoverGateway() {
|
|
224
|
+
// If env vars are set, don't auto-discover (they take priority in runtime-config)
|
|
225
|
+
if (process.env.GATEWAY_URL && process.env.GATEWAY_TOKEN) {
|
|
226
|
+
return {
|
|
227
|
+
url: process.env.GATEWAY_URL,
|
|
228
|
+
token: process.env.GATEWAY_TOKEN,
|
|
229
|
+
source: 'environment',
|
|
230
|
+
verified: false, // Caller can verify if needed
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Try reading OpenClaw config (native path first, then WSL on Windows)
|
|
235
|
+
const configResult = await readOpenClawConfig();
|
|
236
|
+
if (!configResult) {
|
|
237
|
+
log.info('No OpenClaw config found (checked native + WSL) — manual setup required');
|
|
238
|
+
return null;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const details = extractGatewayDetails(configResult.config);
|
|
242
|
+
details.source = configResult.source;
|
|
243
|
+
details.configPath = configResult.path;
|
|
244
|
+
log.info(`Found OpenClaw config (${configResult.source}) — gateway at ${details.url}`);
|
|
245
|
+
|
|
246
|
+
// Verify the gateway is actually running
|
|
247
|
+
const verified = await verifyGateway(details.url, details.token);
|
|
248
|
+
if (verified) {
|
|
249
|
+
log.info(`✓ Gateway verified at ${details.url}`);
|
|
250
|
+
} else {
|
|
251
|
+
log.warn(`Gateway configured at ${details.url} but not responding — may not be running yet`);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
return {
|
|
255
|
+
...details,
|
|
256
|
+
verified,
|
|
257
|
+
};
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/**
|
|
261
|
+
* Get the OpenClaw config path (for display/debugging)
|
|
262
|
+
*/
|
|
263
|
+
export function getConfigPath() {
|
|
264
|
+
return OPENCLAW_CONFIG_PATH;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Get the resolved OpenClaw state directory
|
|
269
|
+
* This may be a WSL UNC path on Windows if that's where the config was found.
|
|
270
|
+
* Other modules (satellite, status) should use this for session file access.
|
|
271
|
+
*
|
|
272
|
+
* @returns {Promise<string>} Resolved state directory path
|
|
273
|
+
*/
|
|
274
|
+
let _resolvedStateDir = null;
|
|
275
|
+
|
|
276
|
+
export async function getOpenClawStateDir() {
|
|
277
|
+
if (_resolvedStateDir) return _resolvedStateDir;
|
|
278
|
+
|
|
279
|
+
// If explicitly set, use that
|
|
280
|
+
if (process.env.OPENCLAW_STATE_DIR) {
|
|
281
|
+
_resolvedStateDir = process.env.OPENCLAW_STATE_DIR;
|
|
282
|
+
return _resolvedStateDir;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Check native path first
|
|
286
|
+
const nativePath = path.join(os.homedir(), '.openclaw');
|
|
287
|
+
const nativeResult = await tryReadConfig(path.join(nativePath, 'openclaw.json'));
|
|
288
|
+
if (nativeResult) {
|
|
289
|
+
_resolvedStateDir = nativePath;
|
|
290
|
+
return _resolvedStateDir;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// On Windows, check WSL
|
|
294
|
+
if (os.platform() === 'win32') {
|
|
295
|
+
const wslResult = await findWSLConfig();
|
|
296
|
+
if (wslResult) {
|
|
297
|
+
// Derive state dir from config path (remove /openclaw.json)
|
|
298
|
+
_resolvedStateDir = path.dirname(wslResult.path);
|
|
299
|
+
return _resolvedStateDir;
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Fallback to native path
|
|
304
|
+
_resolvedStateDir = nativePath;
|
|
305
|
+
return _resolvedStateDir;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export default { discoverGateway, getConfigPath, getOpenClawStateDir };
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Premium Module — Feature gating for Uplink Premium
|
|
3
|
+
*
|
|
4
|
+
* Manages license activation, premium status, and feature access.
|
|
5
|
+
* License keys are validated locally using HMAC-SHA256 (no phone home).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { fileURLToPath } from 'url';
|
|
11
|
+
import { validateLicense } from './license.js';
|
|
12
|
+
import { createLogger } from '../logger.js';
|
|
13
|
+
|
|
14
|
+
const log = createLogger('Premium');
|
|
15
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
16
|
+
|
|
17
|
+
let premiumActive = false;
|
|
18
|
+
let activatedAt = null;
|
|
19
|
+
|
|
20
|
+
// When true, premium features show "Coming Soon" instead of purchase prompts.
|
|
21
|
+
// Flip to false when LemonSqueezy is approved and you're ready to sell.
|
|
22
|
+
const PREMIUM_COMING_SOON = true;
|
|
23
|
+
|
|
24
|
+
// Features gated behind premium
|
|
25
|
+
const PREMIUM_FEATURES = [
|
|
26
|
+
'voice', // TTS + STT
|
|
27
|
+
'themes', // All themes + custom generator (free gets Midnight only)
|
|
28
|
+
'agents', // Agent management panel
|
|
29
|
+
'splitview', // Split view mode
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
// Free users get Midnight only
|
|
33
|
+
const FREE_THEMES = ['midnight'];
|
|
34
|
+
const ALL_THEMES = ['midnight', 'daylight', 'ember', 'forest', 'phantom'];
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Initialize premium from stored config
|
|
38
|
+
* Reads config.json synchronously at startup (runs once)
|
|
39
|
+
*/
|
|
40
|
+
export function initPremium(licenseKey) {
|
|
41
|
+
// If no key passed, try reading from config.json directly
|
|
42
|
+
if (!licenseKey) {
|
|
43
|
+
try {
|
|
44
|
+
const configPath = path.join(__dirname, '..', '..', 'config.json');
|
|
45
|
+
const data = fs.readFileSync(configPath, 'utf8');
|
|
46
|
+
const config = JSON.parse(data);
|
|
47
|
+
licenseKey = config.licenseKey;
|
|
48
|
+
} catch {
|
|
49
|
+
// No config file or no key — that's fine
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!licenseKey) {
|
|
54
|
+
premiumActive = false;
|
|
55
|
+
log.info('Running in free mode');
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const result = validateLicense(licenseKey);
|
|
60
|
+
if (result.valid) {
|
|
61
|
+
premiumActive = true;
|
|
62
|
+
activatedAt = new Date().toISOString();
|
|
63
|
+
log.info('✓ Uplink Premium active');
|
|
64
|
+
return true;
|
|
65
|
+
} else {
|
|
66
|
+
premiumActive = false;
|
|
67
|
+
log.warn('Stored license key is invalid — running in free mode');
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Check if premium is active
|
|
74
|
+
* @returns {boolean}
|
|
75
|
+
*/
|
|
76
|
+
export function isPremium() {
|
|
77
|
+
return premiumActive;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Check if a specific feature is available
|
|
82
|
+
* @param {string} feature - Feature name
|
|
83
|
+
* @returns {boolean}
|
|
84
|
+
*/
|
|
85
|
+
export function hasFeature(feature) {
|
|
86
|
+
if (premiumActive) return true;
|
|
87
|
+
// Free features are anything NOT in PREMIUM_FEATURES
|
|
88
|
+
return !PREMIUM_FEATURES.includes(feature);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Attempt to activate a license key
|
|
93
|
+
* @param {string} key - License key to validate
|
|
94
|
+
* @returns {{ success: boolean, error?: string }}
|
|
95
|
+
*/
|
|
96
|
+
export function activateLicense(key) {
|
|
97
|
+
const result = validateLicense(key);
|
|
98
|
+
if (result.valid) {
|
|
99
|
+
premiumActive = true;
|
|
100
|
+
activatedAt = new Date().toISOString();
|
|
101
|
+
log.info('✓ License activated');
|
|
102
|
+
return { success: true };
|
|
103
|
+
}
|
|
104
|
+
return { success: false, error: 'Invalid license key' };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Deactivate premium (for key removal)
|
|
109
|
+
*/
|
|
110
|
+
export function deactivateLicense() {
|
|
111
|
+
premiumActive = false;
|
|
112
|
+
activatedAt = null;
|
|
113
|
+
log.info('License deactivated — running in free mode');
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get premium status for client
|
|
118
|
+
*/
|
|
119
|
+
export function getPremiumStatus() {
|
|
120
|
+
return {
|
|
121
|
+
active: premiumActive,
|
|
122
|
+
activatedAt,
|
|
123
|
+
comingSoon: PREMIUM_COMING_SOON,
|
|
124
|
+
features: PREMIUM_FEATURES.reduce((acc, f) => {
|
|
125
|
+
acc[f] = premiumActive;
|
|
126
|
+
return acc;
|
|
127
|
+
}, {}),
|
|
128
|
+
themes: premiumActive ? ALL_THEMES : FREE_THEMES,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Express middleware: require premium for a route
|
|
134
|
+
* Returns 403 with upgrade prompt if not premium
|
|
135
|
+
*/
|
|
136
|
+
export function requirePremium(featureName) {
|
|
137
|
+
return (req, res, next) => {
|
|
138
|
+
if (premiumActive) return next();
|
|
139
|
+
return res.status(403).json({
|
|
140
|
+
error: true,
|
|
141
|
+
message: `${featureName || 'This feature'} requires Uplink Premium`,
|
|
142
|
+
code: 'PREMIUM_REQUIRED',
|
|
143
|
+
upgrade: true,
|
|
144
|
+
});
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
export default {
|
|
149
|
+
initPremium,
|
|
150
|
+
isPremium,
|
|
151
|
+
hasFeature,
|
|
152
|
+
activateLicense,
|
|
153
|
+
deactivateLicense,
|
|
154
|
+
getPremiumStatus,
|
|
155
|
+
requirePremium,
|
|
156
|
+
};
|