openclaw-config-web 1.0.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/server.js ADDED
@@ -0,0 +1,382 @@
1
+ const express = require('express');
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const { exec } = require('child_process');
5
+ const crypto = require('crypto');
6
+
7
+ function createServer(options = {}) {
8
+ const app = express();
9
+ const PORT = options.port || process.env.OPENCLAW_WEB_PORT || 3888;
10
+ const CONFIG_PATH = options.configPath || path.join(process.env.HOME, '.openclaw', 'openclaw.json');
11
+ let csrfToken = crypto.randomBytes(32).toString('hex');
12
+
13
+ function authenticate(req, res, next) {
14
+ next();
15
+ }
16
+
17
+ function validateCsrf(req, res, next) {
18
+ next();
19
+ }
20
+
21
+ app.get('/api/csrf-token', (req, res) => {
22
+ res.json({ success: true, token: csrfToken });
23
+ });
24
+
25
+ app.use(express.json());
26
+
27
+ const publicPath = path.join(__dirname, 'public');
28
+ app.use(express.static(publicPath));
29
+
30
+ function readConfig() {
31
+ const content = fs.readFileSync(CONFIG_PATH, 'utf-8');
32
+ return JSON.parse(content);
33
+ }
34
+
35
+ function writeConfig(config) {
36
+ const backupPath = CONFIG_PATH + '.bak.' + Date.now();
37
+ fs.copyFileSync(CONFIG_PATH, backupPath);
38
+ fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), 'utf-8');
39
+ const backups = fs.readdirSync(path.dirname(CONFIG_PATH))
40
+ .filter(f => f.startsWith('openclaw.json.bak.'))
41
+ .sort()
42
+ .reverse();
43
+ backups.slice(5).forEach(f => {
44
+ fs.unlinkSync(path.join(path.dirname(CONFIG_PATH), f));
45
+ });
46
+ }
47
+
48
+ app.get('/api/config', authenticate, (req, res) => {
49
+ try {
50
+ const config = readConfig();
51
+ res.json({ success: true, data: config });
52
+ } catch (err) {
53
+ res.status(500).json({ success: false, error: err.message });
54
+ }
55
+ });
56
+
57
+ app.post('/api/config', authenticate, validateCsrf, (req, res) => {
58
+ try {
59
+ const config = req.body;
60
+ if (!config || typeof config !== 'object') {
61
+ return res.status(400).json({ success: false, error: 'Invalid config format' });
62
+ }
63
+ writeConfig(config);
64
+ res.json({ success: true });
65
+ } catch (err) {
66
+ res.status(500).json({ success: false, error: err.message });
67
+ }
68
+ });
69
+
70
+ app.post('/api/switch-model', authenticate, validateCsrf, (req, res) => {
71
+ try {
72
+ const { modelId, keepFallback } = req.body;
73
+ const config = readConfig();
74
+ const oldFallbacks = config.agents?.defaults?.model?.fallbacks || [];
75
+
76
+ config.agents.defaults.model.primary = modelId;
77
+
78
+ const modelsConfig = { [modelId]: {} };
79
+
80
+ if (keepFallback && oldFallbacks.length > 0) {
81
+ oldFallbacks.forEach(fb => {
82
+ if (fb !== modelId) modelsConfig[fb] = {};
83
+ });
84
+ config.agents.defaults.model.fallbacks = oldFallbacks.filter(fb => fb !== modelId);
85
+ } else {
86
+ config.agents.defaults.model.fallbacks = [];
87
+ }
88
+
89
+ config.agents.defaults.models = modelsConfig;
90
+
91
+ writeConfig(config);
92
+ res.json({ success: true, data: config });
93
+ } catch (err) {
94
+ res.status(500).json({ success: false, error: err.message });
95
+ }
96
+ });
97
+
98
+ app.post('/api/set-fallback', authenticate, validateCsrf, (req, res) => {
99
+ try {
100
+ const { modelId } = req.body;
101
+ const config = readConfig();
102
+ const primary = config.agents?.defaults?.model?.primary;
103
+
104
+ if (modelId === primary) {
105
+ return res.status(400).json({ success: false, error: 'Fallback cannot be same as primary' });
106
+ }
107
+
108
+ let fallbacks = config.agents?.defaults?.model?.fallbacks || [];
109
+ if (fallbacks.includes(modelId)) {
110
+ fallbacks = fallbacks.filter(f => f !== modelId);
111
+ } else {
112
+ fallbacks.push(modelId);
113
+ }
114
+
115
+ config.agents.defaults.model.fallbacks = fallbacks;
116
+
117
+ const modelsConfig = { [primary]: {} };
118
+ fallbacks.forEach(fb => { modelsConfig[fb] = {}; });
119
+ config.agents.defaults.models = modelsConfig;
120
+
121
+ writeConfig(config);
122
+ res.json({ success: true, data: config });
123
+ } catch (err) {
124
+ res.status(500).json({ success: false, error: err.message });
125
+ }
126
+ });
127
+
128
+ app.post('/api/add-provider', authenticate, validateCsrf, (req, res) => {
129
+ try {
130
+ const { provider } = req.body;
131
+ const config = readConfig();
132
+ const providerName = provider.name || `custom_${Date.now()}`;
133
+ delete provider.name;
134
+ config.models.providers[providerName] = provider;
135
+ writeConfig(config);
136
+ res.json({ success: true, providerName, data: config });
137
+ } catch (err) {
138
+ res.status(500).json({ success: false, error: err.message });
139
+ }
140
+ });
141
+
142
+ app.post('/api/update-provider', authenticate, validateCsrf, (req, res) => {
143
+ try {
144
+ const { providerName, provider } = req.body;
145
+ const config = readConfig();
146
+ if (!config.models.providers[providerName]) {
147
+ return res.status(404).json({ success: false, error: 'Provider not found' });
148
+ }
149
+ const existing = config.models.providers[providerName];
150
+ config.models.providers[providerName] = {
151
+ ...existing,
152
+ baseUrl: provider.baseUrl || existing.baseUrl,
153
+ apiKey: provider.apiKey || existing.apiKey,
154
+ api: provider.api || existing.api,
155
+ models: existing.models || []
156
+ };
157
+ writeConfig(config);
158
+ res.json({ success: true, data: config });
159
+ } catch (err) {
160
+ res.status(500).json({ success: false, error: err.message });
161
+ }
162
+ });
163
+
164
+ app.post('/api/update-model', authenticate, validateCsrf, (req, res) => {
165
+ try {
166
+ const { providerName, modelIndex, model } = req.body;
167
+ const config = readConfig();
168
+ if (!config.models.providers[providerName]) {
169
+ return res.status(404).json({ success: false, error: 'Provider not found' });
170
+ }
171
+ if (!config.models.providers[providerName].models[modelIndex]) {
172
+ return res.status(404).json({ success: false, error: 'Model not found' });
173
+ }
174
+ const existing = config.models.providers[providerName].models[modelIndex];
175
+ config.models.providers[providerName].models[modelIndex] = {
176
+ ...existing,
177
+ id: model.id || existing.id,
178
+ name: model.name || existing.name,
179
+ contextWindow: model.contextWindow ?? existing.contextWindow,
180
+ maxTokens: model.maxTokens ?? existing.maxTokens,
181
+ reasoning: model.reasoning ?? existing.reasoning,
182
+ input: model.input || existing.input,
183
+ cost: model.cost || existing.cost
184
+ };
185
+ writeConfig(config);
186
+ res.json({ success: true, data: config });
187
+ } catch (err) {
188
+ res.status(500).json({ success: false, error: err.message });
189
+ }
190
+ });
191
+
192
+ app.post('/api/add-model', authenticate, validateCsrf, (req, res) => {
193
+ try {
194
+ const { providerName, model } = req.body;
195
+ const config = readConfig();
196
+ if (!config.models.providers[providerName]) {
197
+ return res.status(404).json({ success: false, error: 'Provider not found' });
198
+ }
199
+ if (!config.models.providers[providerName].models) {
200
+ config.models.providers[providerName].models = [];
201
+ }
202
+ config.models.providers[providerName].models.push(model);
203
+ writeConfig(config);
204
+ res.json({ success: true, data: config });
205
+ } catch (err) {
206
+ res.status(500).json({ success: false, error: err.message });
207
+ }
208
+ });
209
+
210
+ app.post('/api/delete-model', authenticate, validateCsrf, (req, res) => {
211
+ try {
212
+ const { providerName, modelIndex } = req.body;
213
+ const config = readConfig();
214
+ if (!config.models.providers[providerName]) {
215
+ return res.status(404).json({ success: false, error: 'Provider not found' });
216
+ }
217
+ config.models.providers[providerName].models.splice(modelIndex, 1);
218
+ writeConfig(config);
219
+ res.json({ success: true, data: config });
220
+ } catch (err) {
221
+ res.status(500).json({ success: false, error: err.message });
222
+ }
223
+ });
224
+
225
+ app.post('/api/delete-provider', authenticate, validateCsrf, (req, res) => {
226
+ try {
227
+ const { providerName } = req.body;
228
+ const config = readConfig();
229
+ if (!config.models.providers[providerName]) {
230
+ return res.status(404).json({ success: false, error: 'Provider not found' });
231
+ }
232
+ delete config.models.providers[providerName];
233
+ writeConfig(config);
234
+ res.json({ success: true, data: config });
235
+ } catch (err) {
236
+ res.status(500).json({ success: false, error: err.message });
237
+ }
238
+ });
239
+
240
+ function runSystemctl(cmd) {
241
+ return new Promise((resolve, reject) => {
242
+ exec(`systemctl --user ${cmd} openclaw-gateway 2>&1`, (error, stdout, stderr) => {
243
+ if (error && !stdout.includes('Inactive')) {
244
+ reject(new Error(stdout || stderr || error.message));
245
+ } else {
246
+ resolve(stdout);
247
+ }
248
+ });
249
+ });
250
+ }
251
+
252
+ function getServiceStatus() {
253
+ return new Promise((resolve) => {
254
+ exec('systemctl --user show openclaw-gateway --property=ActiveState,SubState,MainPID 2>&1', (error, stdout) => {
255
+ if (error) {
256
+ resolve({ state: 'unknown', subState: 'unknown', pid: null });
257
+ return;
258
+ }
259
+ const lines = stdout.split('\n');
260
+ const result = { state: 'unknown', subState: 'unknown', pid: null };
261
+ for (const line of lines) {
262
+ if (line.startsWith('ActiveState=')) result.state = line.split('=')[1].trim().toLowerCase();
263
+ if (line.startsWith('SubState=')) result.subState = line.split('=')[1].trim().toLowerCase();
264
+ if (line.startsWith('MainPID=')) result.pid = parseInt(line.split('=')[1].trim()) || null;
265
+ }
266
+ resolve(result);
267
+ });
268
+ });
269
+ }
270
+
271
+ app.get('/api/service/status', authenticate, async (req, res) => {
272
+ try {
273
+ const status = await getServiceStatus();
274
+ res.json({ success: true, data: status });
275
+ } catch (err) {
276
+ res.status(500).json({ success: false, error: err.message });
277
+ }
278
+ });
279
+
280
+ app.post('/api/update-channels', authenticate, validateCsrf, (req, res) => {
281
+ try {
282
+ const { channel, config: channelConfig } = req.body;
283
+ const config = readConfig();
284
+
285
+ if (!config.channels) {
286
+ config.channels = {};
287
+ }
288
+
289
+ config.channels[channel] = {
290
+ ...config.channels[channel],
291
+ ...channelConfig
292
+ };
293
+
294
+ writeConfig(config);
295
+ res.json({ success: true, data: config });
296
+ } catch (err) {
297
+ res.status(500).json({ success: false, error: err.message });
298
+ }
299
+ });
300
+
301
+ app.get('/api/channels', authenticate, (req, res) => {
302
+ try {
303
+ const config = readConfig();
304
+ res.json({ success: true, data: config.channels || {} });
305
+ } catch (err) {
306
+ res.status(500).json({ success: false, error: err.message });
307
+ }
308
+ });
309
+
310
+ app.post('/api/service/start', authenticate, validateCsrf, async (req, res) => {
311
+ try {
312
+ await runSystemctl('start');
313
+ res.json({ success: true });
314
+ } catch (err) {
315
+ res.status(500).json({ success: false, error: err.message });
316
+ }
317
+ });
318
+
319
+ app.post('/api/service/stop', authenticate, validateCsrf, async (req, res) => {
320
+ try {
321
+ await runSystemctl('stop');
322
+ res.json({ success: true });
323
+ } catch (err) {
324
+ res.status(500).json({ success: false, error: err.message });
325
+ }
326
+ });
327
+
328
+ app.post('/api/service/restart', authenticate, validateCsrf, async (req, res) => {
329
+ try {
330
+ await runSystemctl('restart');
331
+ res.json({ success: true });
332
+ } catch (err) {
333
+ res.status(500).json({ success: false, error: err.message });
334
+ }
335
+ });
336
+
337
+ app.post('/api/run-command', authenticate, validateCsrf, (req, res) => {
338
+ const { command } = req.body;
339
+ if (!command) {
340
+ return res.status(400).json({ success: false, error: 'Command is required' });
341
+ }
342
+
343
+ const safeCommands = [
344
+ 'doctor', 'status', 'configure', 'dashboard', 'tui',
345
+ 'models list', 'channels', 'logs', 'health',
346
+ 'models', 'skills', 'devices', 'cron', 'update'
347
+ ];
348
+
349
+ const cmdName = command.split(' ')[0];
350
+ if (!safeCommands.includes(cmdName) && !safeCommands.some(c => command.startsWith(c))) {
351
+ return res.status(403).json({ success: false, error: 'Command not allowed' });
352
+ }
353
+
354
+ exec(`openclaw ${command} 2>&1`, { timeout: 30000 }, (error, stdout, stderr) => {
355
+ if (error) {
356
+ res.json({ success: true, output: stderr || error.message });
357
+ } else {
358
+ res.json({ success: true, output: stdout });
359
+ }
360
+ });
361
+ });
362
+
363
+ return { app, PORT };
364
+ }
365
+
366
+ function start(options = {}) {
367
+ const { app, PORT } = createServer(options);
368
+ return app.listen(PORT, '0.0.0.0', () => {
369
+ console.log(`OpenClaw 配置管理界面运行在: http://localhost:${PORT}`);
370
+ const os = require('os');
371
+ const interfaces = os.networkInterfaces();
372
+ for (const name of Object.keys(interfaces)) {
373
+ for (const iface of interfaces[name]) {
374
+ if (iface.family === 'IPv4' && !iface.internal) {
375
+ console.log(` 局域网访问: http://${iface.address}:${PORT}`);
376
+ }
377
+ }
378
+ }
379
+ });
380
+ }
381
+
382
+ module.exports = { createServer, start };