neoagent 2.4.1-beta.43 → 2.4.1-beta.45

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.
@@ -0,0 +1,321 @@
1
+ 'use strict';
2
+
3
+ const path = require('path');
4
+ const express = require('express');
5
+ const { spawn } = require('child_process');
6
+ const { requireAdminAuth } = require('../middleware/adminAuth');
7
+ const { getVersionInfo } = require('../utils/version');
8
+ const {
9
+ readUpdateStatus,
10
+ writeUpdateStatusFile: writeUpdateStatus,
11
+ } = require('../utils/update_status');
12
+ const {
13
+ parseReleaseChannel,
14
+ writeReleaseChannelToEnvFile,
15
+ getReleaseChannelBranchPolicy,
16
+ getReleaseChannelNpmPolicy,
17
+ } = require('../../runtime/release_channel');
18
+ const { APP_DIR, ENV_FILE, upsertEnvValue } = require('../../runtime/paths');
19
+ const { isManagedDeployment } = require('../utils/deployment');
20
+ const rateLimit = require('express-rate-limit');
21
+
22
+ const router = express.Router();
23
+ const ADMIN_DIR = path.join(__dirname, '..', 'admin');
24
+
25
+ const loginLimiter = rateLimit({
26
+ windowMs: 15 * 60 * 1000,
27
+ max: 20,
28
+ standardHeaders: true,
29
+ legacyHeaders: false,
30
+ skipSuccessfulRequests: true,
31
+ message: { error: 'Too many login attempts, try again later' },
32
+ });
33
+
34
+ const updateTriggerLimiter = rateLimit({
35
+ windowMs: 15 * 60 * 1000,
36
+ max: 5,
37
+ standardHeaders: true,
38
+ legacyHeaders: false,
39
+ message: { error: 'Too many update requests, try again later' },
40
+ });
41
+
42
+ // --- Auth ---
43
+
44
+ router.get('/login', (req, res) => {
45
+ if (req.session?.isAdmin) return res.redirect('/admin');
46
+ res.sendFile(path.join(ADMIN_DIR, 'login.html'));
47
+ });
48
+
49
+ router.post('/api/login', loginLimiter, express.json(), (req, res) => {
50
+ const { username, password } = req.body || {};
51
+ const expectedUsername = process.env.ADMIN_USERNAME || 'admin';
52
+ const expectedPassword = process.env.ADMIN_PASSWORD || '';
53
+ if (!expectedPassword) {
54
+ return res.status(503).json({ error: 'Admin credentials not configured. Run `neoagent setup`.' });
55
+ }
56
+ if (username === expectedUsername && password === expectedPassword) {
57
+ req.session.isAdmin = true;
58
+ req.session.save((err) => {
59
+ if (err) return res.status(500).json({ error: 'Session error' });
60
+ res.json({ ok: true });
61
+ });
62
+ } else {
63
+ res.status(401).json({ error: 'Invalid credentials' });
64
+ }
65
+ });
66
+
67
+ router.post('/api/logout', (req, res) => {
68
+ req.session.destroy(() => res.json({ ok: true }));
69
+ });
70
+
71
+ // --- Protected API ---
72
+
73
+ router.get('/api/logs', requireAdminAuth, (req, res) => {
74
+ const logHistory = req.app.locals.logHistory || [];
75
+ res.json({ logs: logHistory });
76
+ });
77
+
78
+ router.get('/api/version', requireAdminAuth, (req, res) => {
79
+ const version = getVersionInfo();
80
+ const status = readUpdateStatus();
81
+ res.json({
82
+ version: version.version,
83
+ installedVersion: version.installedVersion,
84
+ packageVersion: version.packageVersion,
85
+ gitVersion: version.gitVersion,
86
+ gitSha: version.gitSha,
87
+ gitBranch: version.gitBranch,
88
+ releaseChannel: status.releaseChannel || version.releaseChannel,
89
+ deploymentMode: version.deploymentMode,
90
+ deploymentProfile: version.deploymentProfile,
91
+ allowSelfUpdate: version.allowSelfUpdate,
92
+ updateStatus: {
93
+ state: status.state,
94
+ progress: status.progress,
95
+ phase: status.phase,
96
+ message: status.message,
97
+ },
98
+ uptime: process.uptime(),
99
+ nodeVersion: process.version,
100
+ });
101
+ });
102
+
103
+ router.get('/api/health', requireAdminAuth, async (req, res) => {
104
+ const runtimeManager = req.app?.locals?.runtimeManager;
105
+ const desktopRegistry = req.app?.locals?.desktopCompanionRegistry;
106
+ const extensionRegistry = req.app?.locals?.browserExtensionRegistry;
107
+ const results = [];
108
+
109
+ results.push({ id: 'backend', label: 'Backend server', passed: true, detail: 'Running' });
110
+
111
+ const version = getVersionInfo();
112
+ results.push({
113
+ id: 'version',
114
+ label: 'Server version',
115
+ passed: true,
116
+ detail: version.version || version.packageVersion || 'Unknown',
117
+ });
118
+
119
+ try {
120
+ const db = require('../db/database');
121
+ db.prepare('SELECT 1').get();
122
+ results.push({ id: 'database', label: 'Database', passed: true, detail: 'SQLite connected' });
123
+ } catch (err) {
124
+ results.push({ id: 'database', label: 'Database', passed: false, detail: String(err?.message || err).slice(0, 120) });
125
+ }
126
+
127
+ const updateStatus = readUpdateStatus();
128
+ results.push({
129
+ id: 'update',
130
+ label: 'Update status',
131
+ passed: updateStatus.state !== 'failed',
132
+ detail: updateStatus.state === 'idle'
133
+ ? 'No update running'
134
+ : `${updateStatus.state} — ${updateStatus.message || ''}`.trim(),
135
+ });
136
+
137
+ const { getRuntimeValidation } = require('../services/runtime/validation');
138
+ const runtimeValidation = getRuntimeValidation(runtimeManager);
139
+ const runtimeReady = Boolean(runtimeValidation?.ready);
140
+ results.push({
141
+ id: 'vm_runtime',
142
+ label: 'Cloud VM runtime',
143
+ passed: runtimeReady,
144
+ detail: runtimeReady ? 'Available' : String(runtimeValidation?.issues?.[0] || 'Not configured'),
145
+ });
146
+
147
+ if (desktopRegistry) {
148
+ try {
149
+ const connectedUsers = [];
150
+ for (const socket of (req.app?.locals?.io?.sockets?.sockets?.values?.() || [])) {
151
+ const uid = socket.request?.session?.userId;
152
+ if (uid && !connectedUsers.includes(uid)) connectedUsers.push(uid);
153
+ }
154
+ results.push({
155
+ id: 'desktop',
156
+ label: 'Desktop companion',
157
+ passed: true,
158
+ detail: 'Registry available',
159
+ });
160
+ } catch {
161
+ results.push({ id: 'desktop', label: 'Desktop companion', passed: false, detail: 'Registry error' });
162
+ }
163
+ }
164
+
165
+ if (extensionRegistry) {
166
+ results.push({ id: 'extension', label: 'Chrome extension registry', passed: true, detail: 'Available' });
167
+ }
168
+
169
+ const configuredProviders = [];
170
+ if (process.env.ANTHROPIC_API_KEY) configuredProviders.push('Anthropic');
171
+ if (process.env.OPENAI_API_KEY) configuredProviders.push('OpenAI');
172
+ if (process.env.XAI_API_KEY) configuredProviders.push('xAI');
173
+ if (process.env.GOOGLE_AI_KEY) configuredProviders.push('Google');
174
+ if (process.env.OPENROUTER_API_KEY) configuredProviders.push('OpenRouter');
175
+ results.push({
176
+ id: 'ai_providers',
177
+ label: 'AI providers',
178
+ passed: configuredProviders.length > 0,
179
+ detail: configuredProviders.length > 0
180
+ ? configuredProviders.join(', ')
181
+ : 'No providers configured',
182
+ });
183
+
184
+ if (process.env.DEEPGRAM_API_KEY) {
185
+ results.push({ id: 'deepgram', label: 'Deepgram (voice)', passed: true, detail: 'API key configured' });
186
+ }
187
+
188
+ const allPassed = results.every((r) => r.passed);
189
+ res.json({ passed: allPassed, results });
190
+ });
191
+
192
+ router.get('/api/config', requireAdminAuth, (req, res) => {
193
+ const safe = [
194
+ 'PORT', 'NODE_ENV', 'PUBLIC_URL', 'NEOAGENT_DEPLOYMENT_MODE',
195
+ 'NEOAGENT_PROFILE', 'NEOAGENT_RELEASE_CHANNEL', 'ALLOWED_ORIGINS',
196
+ 'SECURE_COOKIES', 'TRUST_PROXY', 'ADMIN_USERNAME',
197
+ ];
198
+ const config = {};
199
+ for (const key of safe) {
200
+ config[key] = process.env[key] || '';
201
+ }
202
+ res.json({ config });
203
+ });
204
+
205
+ router.post('/api/update', requireAdminAuth, updateTriggerLimiter, (req, res) => {
206
+ if (isManagedDeployment()) {
207
+ return res.status(403).json({ error: 'Updates are managed by this deployment.' });
208
+ }
209
+ const status = readUpdateStatus();
210
+ if (status.state === 'running') {
211
+ return res.status(409).json({ error: 'An update is already running' });
212
+ }
213
+ console.log('[Admin] Triggering update-runner...');
214
+ const child = spawn(process.execPath, ['scripts/update-runner.js'], {
215
+ detached: true,
216
+ stdio: 'ignore',
217
+ cwd: APP_DIR,
218
+ });
219
+ writeUpdateStatus({
220
+ state: 'running',
221
+ progress: 1,
222
+ phase: 'starting',
223
+ message: 'Launching update job',
224
+ startedAt: new Date().toISOString(),
225
+ completedAt: null,
226
+ versionBefore: null,
227
+ versionAfter: null,
228
+ runnerPid: child.pid,
229
+ changelog: [],
230
+ logs: [],
231
+ });
232
+ child.once('error', (error) => {
233
+ writeUpdateStatus({
234
+ state: 'failed',
235
+ progress: 100,
236
+ phase: 'failed',
237
+ message: `Failed to launch update job: ${error.message}`,
238
+ completedAt: new Date().toISOString(),
239
+ runnerPid: null,
240
+ });
241
+ });
242
+ child.unref();
243
+ res.json({ ok: true, message: 'Update triggered', pid: child.pid });
244
+ });
245
+
246
+ router.put('/api/update/channel', requireAdminAuth, (req, res) => {
247
+ if (isManagedDeployment()) {
248
+ return res.status(403).json({ error: 'Release channel changes are managed by this deployment.' });
249
+ }
250
+ const requested = req.body?.channel;
251
+ const releaseChannel = parseReleaseChannel(requested);
252
+ if (!releaseChannel) {
253
+ return res.status(400).json({ error: 'Release channel must be "stable" or "beta".' });
254
+ }
255
+ writeReleaseChannelToEnvFile(releaseChannel);
256
+ process.env.NEOAGENT_RELEASE_CHANNEL = releaseChannel;
257
+ res.json({
258
+ ok: true,
259
+ releaseChannel,
260
+ targetBranch: getReleaseChannelBranchPolicy(releaseChannel),
261
+ npmDistTag: getReleaseChannelNpmPolicy(releaseChannel),
262
+ });
263
+ });
264
+
265
+ // --- Providers ---
266
+
267
+ const PROVIDERS = [
268
+ { key: 'ANTHROPIC_API_KEY', label: 'Anthropic (Claude)', type: 'key' },
269
+ { key: 'OPENAI_API_KEY', label: 'OpenAI', type: 'key' },
270
+ { key: 'XAI_API_KEY', label: 'xAI (Grok)', type: 'key' },
271
+ { key: 'GOOGLE_AI_KEY', label: 'Google (Gemini)', type: 'key' },
272
+ { key: 'MINIMAX_API_KEY', label: 'MiniMax', type: 'key' },
273
+ { key: 'NVIDIA_API_KEY', label: 'NVIDIA NIM', type: 'key' },
274
+ { key: 'OPENROUTER_API_KEY', label: 'OpenRouter', type: 'key' },
275
+ { key: 'BRAVE_SEARCH_API_KEY', label: 'Brave Search', type: 'key' },
276
+ { key: 'DEEPGRAM_API_KEY', label: 'Deepgram (Voice)', type: 'key' },
277
+ { key: 'OLLAMA_URL', label: 'Ollama (Local)', type: 'url' },
278
+ ];
279
+
280
+ const ALLOWED_PROVIDER_KEYS = new Set(PROVIDERS.map((p) => p.key));
281
+
282
+ router.get('/api/providers', requireAdminAuth, (req, res) => {
283
+ const result = PROVIDERS.map(({ key, label, type }) => {
284
+ const value = process.env[key] || '';
285
+ let hint = '';
286
+ if (value) {
287
+ hint = type === 'url'
288
+ ? value
289
+ : value.length > 8
290
+ ? `${value.slice(0, 4)}${'•'.repeat(4)}${value.slice(-4)}`
291
+ : '•'.repeat(value.length);
292
+ }
293
+ return { key, label, type, configured: Boolean(value), hint };
294
+ });
295
+ res.json({ providers: result });
296
+ });
297
+
298
+ router.put('/api/providers', requireAdminAuth, express.json(), (req, res) => {
299
+ const { key, value } = req.body || {};
300
+ if (!ALLOWED_PROVIDER_KEYS.has(key)) {
301
+ return res.status(400).json({ error: 'Unknown provider key' });
302
+ }
303
+ const trimmed = String(value || '').trim();
304
+ upsertEnvValue(ENV_FILE, key, trimmed);
305
+ if (trimmed) {
306
+ process.env[key] = trimmed;
307
+ } else {
308
+ delete process.env[key];
309
+ }
310
+ res.json({ ok: true });
311
+ });
312
+
313
+ // --- Static files ---
314
+
315
+ router.use(express.static(ADMIN_DIR));
316
+
317
+ router.get('*', requireAdminAuth, (req, res) => {
318
+ res.sendFile(path.join(ADMIN_DIR, 'index.html'));
319
+ });
320
+
321
+ module.exports = router;
@@ -562,8 +562,16 @@ router.delete('/:key', (req, res) => {
562
562
  res.json({ success: true });
563
563
  });
564
564
 
565
+ function requireAdminSession(req, res, next) {
566
+ if (req.session?.isAdmin === true) return next();
567
+ return res.status(403).json({
568
+ success: false,
569
+ error: 'Server updates are only available from the admin dashboard.',
570
+ });
571
+ }
572
+
565
573
  // Trigger auto-update script
566
- router.post('/update', requireAuth, updateTriggerLimiter, (req, res) => {
574
+ router.post('/update', requireAuth, requireAdminSession, updateTriggerLimiter, (req, res) => {
567
575
  if (isManagedDeployment()) {
568
576
  return res.status(403).json({
569
577
  success: false,
@@ -611,7 +619,7 @@ router.post('/update', requireAuth, updateTriggerLimiter, (req, res) => {
611
619
  res.json({ success: true, message: 'Update triggered', pid: child.pid });
612
620
  });
613
621
 
614
- router.put('/update/channel', (req, res) => {
622
+ router.put('/update/channel', requireAuth, requireAdminSession, (req, res) => {
615
623
  if (isManagedDeployment()) {
616
624
  return res.status(403).json({
617
625
  success: false,
@@ -366,7 +366,23 @@ class WidgetService {
366
366
  throw new Error('Widget not found.');
367
367
  }
368
368
  if (existingRow.is_system) {
369
- throw new Error('System widgets cannot be edited directly.');
369
+ if (!('enabled' in input)) {
370
+ throw new Error('System widgets cannot be edited directly.');
371
+ }
372
+ // Only the enabled state may be toggled on system widgets.
373
+ const newEnabled = input.enabled !== false;
374
+ db.prepare(
375
+ `UPDATE ai_widgets SET enabled = ?, updated_at = datetime('now') WHERE id = ? AND user_id = ?`
376
+ ).run(newEnabled ? 1 : 0, widgetId, userId);
377
+ if (existingRow.scheduled_task_id && this.taskRuntime) {
378
+ await this.taskRuntime.updateTask(
379
+ existingRow.scheduled_task_id,
380
+ userId,
381
+ { enabled: newEnabled },
382
+ { allowManaged: true },
383
+ );
384
+ }
385
+ return this.getWidget(userId, widgetId);
370
386
  }
371
387
 
372
388
  const current = this._serializeWidget(existingRow, null, []);
@@ -1,5 +1,8 @@
1
1
  'use strict';
2
2
 
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+
3
6
  const SENSITIVE_KEY_RE = /(?:^|_|-)(?:token|secret|password|api[_-]?key|authorization|cookie|session(?:id)?|sid)(?:$|_|-)/i;
4
7
 
5
8
  function redactSecrets(input) {
@@ -96,22 +99,57 @@ function logRequestSummary(level, req, message, extra = null) {
96
99
  console[level](redactSecrets(`${prefix} ${message}`), summary);
97
100
  }
98
101
 
102
+ function getLogFile() {
103
+ try {
104
+ const { DATA_DIR } = require('../../runtime/paths');
105
+ return path.join(DATA_DIR, 'server-logs.jsonl');
106
+ } catch {
107
+ return path.join(require('os').homedir(), '.neoagent', 'data', 'server-logs.jsonl');
108
+ }
109
+ }
110
+
111
+ function loadPersistedLogs(logFile) {
112
+ try {
113
+ const raw = fs.readFileSync(logFile, 'utf8');
114
+ const entries = [];
115
+ for (const line of raw.split('\n')) {
116
+ if (!line.trim()) continue;
117
+ try { entries.push(JSON.parse(line)); } catch {}
118
+ }
119
+ return entries.slice(-1000);
120
+ } catch {
121
+ return [];
122
+ }
123
+ }
124
+
99
125
  /**
100
- * Intercepts console methods and broadcasts logs via Socket.IO
101
- * @param {import('socket.io').Server} io
126
+ * Intercepts console methods and broadcasts logs via Socket.IO.
127
+ * Persists up to 1000 entries to disk across server restarts.
128
+ * @param {import('socket.io').Server} io
102
129
  */
103
130
  function setupConsoleInterceptor(io) {
104
- const logHistory = [];
105
- const MAX_LOG_HISTORY = 200;
131
+ const MAX_LOG_HISTORY = 1000;
132
+ const TRIM_AT = 1100;
133
+ const logFile = getLogFile();
106
134
  const allowLogHistoryRequests = String(process.env.NEOAGENT_ENABLE_LOG_HISTORY_REQUESTS || '').trim().toLowerCase() === 'true';
107
135
 
136
+ try { fs.mkdirSync(path.dirname(logFile), { recursive: true }); } catch {}
137
+ const logHistory = loadPersistedLogs(logFile);
138
+
108
139
  function broadcastLog(type, args) {
109
140
  const msg = formatLogArgs(args);
110
141
  const logEntry = { type, message: msg, timestamp: new Date().toISOString() };
111
142
  logHistory.push(logEntry);
112
- if (logHistory.length > MAX_LOG_HISTORY) logHistory.shift();
113
143
 
114
- // Broadcast only to authenticated user rooms
144
+ try { fs.appendFileSync(logFile, JSON.stringify(logEntry) + '\n'); } catch {}
145
+
146
+ if (logHistory.length > TRIM_AT) {
147
+ logHistory.splice(0, logHistory.length - MAX_LOG_HISTORY);
148
+ try {
149
+ fs.writeFileSync(logFile, logHistory.map(e => JSON.stringify(e)).join('\n') + '\n');
150
+ } catch {}
151
+ }
152
+
115
153
  for (const [, socket] of io.sockets.sockets) {
116
154
  const uid = socket.request?.session?.userId;
117
155
  if (uid) socket.emit('server:log', logEntry);