neoagent 2.5.2-beta.9 → 3.0.1-beta.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.
Files changed (52) hide show
  1. package/README.md +5 -1
  2. package/docs/integrations.md +11 -7
  3. package/flutter_app/lib/main_controller.dart +34 -0
  4. package/flutter_app/lib/main_security.dart +19 -0
  5. package/lib/schema_migrations.js +224 -0
  6. package/package.json +2 -2
  7. package/server/admin/admin.css +29 -0
  8. package/server/admin/admin.js +81 -1
  9. package/server/admin/index.html +97 -9
  10. package/server/db/database.js +57 -1
  11. package/server/public/.last_build_id +1 -1
  12. package/server/public/flutter_bootstrap.js +1 -1
  13. package/server/public/main.dart.js +32788 -32752
  14. package/server/routes/security.js +19 -0
  15. package/server/routes/settings.js +1 -0
  16. package/server/routes/skills.js +9 -5
  17. package/server/services/ai/deliverables/artifact_helpers.js +92 -9
  18. package/server/services/ai/deliverables/selector.js +17 -0
  19. package/server/services/ai/hooks.js +3 -2
  20. package/server/services/ai/learning.js +8 -3
  21. package/server/services/ai/loop/agent_engine_core.js +67 -0
  22. package/server/services/ai/loop/blank_recovery.js +37 -0
  23. package/server/services/ai/loop/conversation_loop.js +391 -252
  24. package/server/services/ai/loop/error_recovery.js +38 -0
  25. package/server/services/ai/loop/messaging_delivery.js +52 -6
  26. package/server/services/ai/loop/progress_classification.js +178 -0
  27. package/server/services/ai/loop/progress_monitor.js +6 -5
  28. package/server/services/ai/loop/tool_dispatch.js +1 -0
  29. package/server/services/ai/loopPolicy.js +15 -6
  30. package/server/services/ai/messagingFallback.js +23 -1
  31. package/server/services/ai/preModelCompaction.js +31 -0
  32. package/server/services/ai/repetitionGuard.js +47 -2
  33. package/server/services/ai/settings.js +8 -0
  34. package/server/services/ai/systemPrompt.js +24 -0
  35. package/server/services/ai/taskAnalysis.js +10 -0
  36. package/server/services/ai/toolEvidence.js +39 -3
  37. package/server/services/ai/toolResult.js +29 -0
  38. package/server/services/ai/toolRunner.js +262 -55
  39. package/server/services/ai/toolSelector.js +20 -3
  40. package/server/services/ai/tools.js +201 -31
  41. package/server/services/cli/shell_worker_pool.js +87 -5
  42. package/server/services/integrations/github/common.js +2 -2
  43. package/server/services/integrations/github/repos.js +123 -55
  44. package/server/services/manager.js +16 -0
  45. package/server/services/memory/manager.js +39 -24
  46. package/server/services/runtime/docker-vm-manager.js +21 -1
  47. package/server/services/runtime/manager.js +6 -2
  48. package/server/services/security/approval_gate_service.js +108 -5
  49. package/server/services/security/tool_categories.js +113 -4
  50. package/server/services/security/tool_security_hook.js +7 -2
  51. package/server/services/skills/runtime.js +2 -0
  52. package/server/services/workspace/manager.js +56 -0
@@ -5,28 +5,120 @@ const db = require('../../db/database');
5
5
  const { getCategoryForTool } = require('./tool_categories');
6
6
 
7
7
  const APPROVAL_TIMEOUT_MS = 30_000;
8
+ const SESSION_GRANT_TTL_MS = 12 * 60 * 60 * 1000;
8
9
 
9
10
  class ApprovalGateService {
10
11
  constructor({ io }) {
11
12
  this._io = io;
12
- /** @type {Map<string, { resolve: Function, timer: NodeJS.Timeout }>} */
13
+ /** @type {Map<string, { resolve: Function, timer: NodeJS.Timeout, userId: number|string, runId: string|null, toolName: string, toolArgs: object }>} */
13
14
  this._pending = new Map();
14
15
  /** @type {Set<string>} key = `${userId}:${runId}:${toolName}` */
15
16
  this._sessionGrants = new Set();
17
+ this._loadSessionGrants();
16
18
  }
17
19
 
18
20
  hasSessionGrant(userId, runId, toolName) {
19
21
  return this._sessionGrants.has(`${userId}:${runId}:${toolName}`);
20
22
  }
21
23
 
24
+ _grantKey(userId, runId, toolName) {
25
+ return `${userId}:${runId}:${toolName}`;
26
+ }
27
+
28
+ _loadSessionGrants() {
29
+ try {
30
+ db.prepare(
31
+ `DELETE FROM approval_session_grants
32
+ WHERE expires_at <= datetime('now')`
33
+ ).run();
34
+ const rows = db.prepare(
35
+ `SELECT user_id, run_id, tool_name
36
+ FROM approval_session_grants
37
+ WHERE expires_at > datetime('now')`
38
+ ).all();
39
+ for (const row of rows) {
40
+ this._sessionGrants.add(this._grantKey(row.user_id, row.run_id, row.tool_name));
41
+ }
42
+ } catch (err) {
43
+ console.warn('[ApprovalGate] Failed to load session grants:', err.message);
44
+ }
45
+ }
46
+
47
+ _persistSessionGrant(userId, runId, toolName) {
48
+ const expiresAt = new Date(Date.now() + SESSION_GRANT_TTL_MS).toISOString();
49
+ db.prepare(
50
+ `INSERT INTO approval_session_grants (
51
+ user_id, run_id, tool_name, expires_at, updated_at
52
+ ) VALUES (?, ?, ?, ?, datetime('now'))
53
+ ON CONFLICT(user_id, run_id, tool_name) DO UPDATE SET
54
+ expires_at = excluded.expires_at,
55
+ updated_at = excluded.updated_at`
56
+ ).run(userId, runId, toolName, expiresAt);
57
+ }
58
+
59
+ _persistPendingApproval(approvalId, userId, runId, toolName, toolArgs, category, expiresAt) {
60
+ db.prepare(
61
+ `INSERT INTO pending_approvals (
62
+ id, user_id, run_id, tool_name, tool_args_json, category, status, expires_at, updated_at
63
+ ) VALUES (?, ?, ?, ?, ?, ?, 'pending', ?, datetime('now'))`
64
+ ).run(
65
+ approvalId,
66
+ userId,
67
+ runId,
68
+ toolName,
69
+ JSON.stringify(toolArgs ?? {}),
70
+ category,
71
+ expiresAt,
72
+ );
73
+ }
74
+
75
+ _updatePendingApprovalStatus(approvalId, status, scope = null) {
76
+ db.prepare(
77
+ `UPDATE pending_approvals
78
+ SET status = ?,
79
+ scope = COALESCE(?, scope),
80
+ decided_at = CASE WHEN ? = 'pending' THEN decided_at ELSE COALESCE(decided_at, datetime('now')) END,
81
+ updated_at = datetime('now')
82
+ WHERE id = ?`
83
+ ).run(status, scope, status, approvalId);
84
+ }
85
+
86
+ getStoredApproval(approvalId, userId) {
87
+ try {
88
+ return db.prepare(
89
+ `SELECT id, run_id, tool_name, status, scope, expires_at, decided_at
90
+ FROM pending_approvals
91
+ WHERE id = ? AND user_id = ?`
92
+ ).get(approvalId, userId);
93
+ } catch {
94
+ return null;
95
+ }
96
+ }
97
+
98
+ shutdown(reason = 'Approval expired because the server restarted.') {
99
+ for (const [approvalId, entry] of this._pending.entries()) {
100
+ clearTimeout(entry.timer);
101
+ this._pending.delete(approvalId);
102
+ this._updatePendingApprovalStatus(approvalId, 'expired', 'once');
103
+ this._io.to(`user:${entry.userId}`).emit('tool:approval_resolved', {
104
+ approvalId,
105
+ decision: 'expired',
106
+ reason,
107
+ });
108
+ this._logDecision(entry.userId, entry.runId, entry.toolName, entry.toolArgs, 'timeout', 'once');
109
+ entry.resolve('expired');
110
+ }
111
+ }
112
+
22
113
  /**
23
114
  * Emits tool:approval_required and waits for a decision.
24
- * Resolves to 'approved', 'denied', or 'timeout'.
115
+ * Resolves to 'approved', 'denied', 'timeout', or 'expired'.
25
116
  */
26
117
  requestApproval(userId, runId, toolName, toolArgs) {
27
118
  const approvalId = randomUUID();
28
119
  const expiresAt = new Date(Date.now() + APPROVAL_TIMEOUT_MS).toISOString();
29
120
  const category = getCategoryForTool(toolName, toolArgs) ?? 'unknown';
121
+ this._persistPendingApproval(approvalId, userId, runId, toolName, toolArgs, category, expiresAt);
30
122
 
31
123
  const payload = { approvalId, runId, toolName, toolArgs, category, expiresAt };
32
124
  this._io.to(`user:${userId}`).emit('tool:approval_required', payload);
@@ -35,6 +127,7 @@ class ApprovalGateService {
35
127
  const timer = setTimeout(() => {
36
128
  if (this._pending.has(approvalId)) {
37
129
  this._pending.delete(approvalId);
130
+ this._updatePendingApprovalStatus(approvalId, 'timeout', 'once');
38
131
  this._io.to(`user:${userId}`).emit('tool:approval_resolved', {
39
132
  approvalId, decision: 'timeout',
40
133
  });
@@ -43,7 +136,14 @@ class ApprovalGateService {
43
136
  }
44
137
  }, APPROVAL_TIMEOUT_MS);
45
138
 
46
- this._pending.set(approvalId, { resolve, timer });
139
+ this._pending.set(approvalId, {
140
+ resolve,
141
+ timer,
142
+ userId,
143
+ runId,
144
+ toolName,
145
+ toolArgs: toolArgs ?? {},
146
+ });
47
147
  });
48
148
  }
49
149
 
@@ -63,14 +163,17 @@ class ApprovalGateService {
63
163
  const normalizedScope = ['once', 'session', 'always'].includes(scope) ? scope : 'once';
64
164
 
65
165
  if (normalizedDecision === 'approved' && normalizedScope === 'session') {
66
- this._sessionGrants.add(`${userId}:${runId}:${toolName}`);
166
+ this._sessionGrants.add(this._grantKey(userId, runId, toolName));
167
+ this._persistSessionGrant(userId, runId, toolName);
67
168
  }
68
169
  // 'always' scope is handled by the route (sets policy to 'allow') and
69
170
  // also acts as a session grant for the current run.
70
171
  if (normalizedDecision === 'approved' && normalizedScope === 'always') {
71
- this._sessionGrants.add(`${userId}:${runId}:${toolName}`);
172
+ this._sessionGrants.add(this._grantKey(userId, runId, toolName));
173
+ this._persistSessionGrant(userId, runId, toolName);
72
174
  }
73
175
 
176
+ this._updatePendingApprovalStatus(approvalId, normalizedDecision, normalizedScope);
74
177
  const logScope = normalizedScope === 'always' ? 'session' : normalizedScope;
75
178
  this._logDecision(userId, runId, toolName, toolArgs, normalizedDecision, logScope);
76
179
  this._io.to(`user:${userId}`).emit('tool:approval_resolved', { approvalId, decision: normalizedDecision });
@@ -27,6 +27,7 @@ const TOOL_CATEGORIES = {
27
27
  'update_ai_widget',
28
28
  'delete_ai_widget',
29
29
  ],
30
+ external: [],
30
31
  };
31
32
 
32
33
  // Tools that bypass all policy checks — read-only or always safe
@@ -71,6 +72,98 @@ const SAFE_TOOLS = new Set([
71
72
  'recordings_get',
72
73
  ]);
73
74
 
75
+ const BUILT_IN_TOOLS = new Set([
76
+ 'execute_command',
77
+ 'browser_navigate',
78
+ 'browser_click',
79
+ 'browser_type',
80
+ 'browser_extract',
81
+ 'browser_screenshot',
82
+ 'browser_evaluate',
83
+ 'desktop_list_devices',
84
+ 'desktop_select_device',
85
+ 'desktop_observe',
86
+ 'desktop_click',
87
+ 'desktop_drag',
88
+ 'desktop_scroll',
89
+ 'desktop_type',
90
+ 'desktop_press_key',
91
+ 'desktop_launch_app',
92
+ 'desktop_get_tree',
93
+ 'android_start_emulator',
94
+ 'android_stop_emulator',
95
+ 'android_list_devices',
96
+ 'android_open_app',
97
+ 'android_open_intent',
98
+ 'android_tap',
99
+ 'android_long_press',
100
+ 'android_type',
101
+ 'android_swipe',
102
+ 'android_press_key',
103
+ 'android_wait_for',
104
+ 'android_observe',
105
+ 'android_dump_ui',
106
+ 'android_screenshot',
107
+ 'android_list_apps',
108
+ 'android_install_apk',
109
+ 'android_shell',
110
+ 'web_search',
111
+ 'memory_save',
112
+ 'memory_recall',
113
+ 'session_search',
114
+ 'memory_update_core',
115
+ 'memory_write',
116
+ 'memory_read',
117
+ 'make_call',
118
+ 'send_message',
119
+ 'read_file',
120
+ 'read_files',
121
+ 'write_file',
122
+ 'edit_file',
123
+ 'replace_file_range',
124
+ 'list_directory',
125
+ 'search_files',
126
+ 'code_navigate',
127
+ 'query_structured_data',
128
+ 'http_request',
129
+ 'create_skill',
130
+ 'list_skills',
131
+ 'update_skill',
132
+ 'delete_skill',
133
+ 'think',
134
+ 'activate_tools',
135
+ 'spawn_subagent',
136
+ 'delegate_to_agent',
137
+ 'list_subagents',
138
+ 'wait_subagent',
139
+ 'cancel_subagent',
140
+ 'notify_user',
141
+ 'create_task',
142
+ 'list_tasks',
143
+ 'delete_task',
144
+ 'update_task',
145
+ 'create_ai_widget',
146
+ 'list_ai_widgets',
147
+ 'update_ai_widget',
148
+ 'delete_ai_widget',
149
+ 'mcp_add_server',
150
+ 'mcp_list_servers',
151
+ 'mcp_remove_server',
152
+ 'generate_image',
153
+ 'generate_table',
154
+ 'generate_graph',
155
+ 'analyze_image',
156
+ 'ocr_extract',
157
+ 'read_health_data',
158
+ 'recordings_list',
159
+ 'recordings_get',
160
+ 'recordings_search',
161
+ 'social_video_extract',
162
+ 'task_complete',
163
+ 'send_interim_update',
164
+ 'save_widget_snapshot',
165
+ ]);
166
+
74
167
  // category → policy for users with no DB row yet
75
168
  const DEFAULT_POLICY = {
76
169
  shell: 'require_approval',
@@ -80,6 +173,7 @@ const DEFAULT_POLICY = {
80
173
  browser_privileged: 'require_approval',
81
174
  network_write: 'require_approval',
82
175
  skill_mutation: 'deny',
176
+ external: 'require_approval',
83
177
  };
84
178
 
85
179
  // Reverse map: tool → category, built once
@@ -91,15 +185,30 @@ for (const [category, tools] of Object.entries(TOOL_CATEGORIES)) {
91
185
  }
92
186
 
93
187
  /**
94
- * Returns the category for a tool, or null if the tool is uncategorised (safe).
95
- * For http_request, write methods map to network_write; read methods return null.
188
+ * Returns the category for a tool.
189
+ * - Known built-in tools may return null when they are read-only or otherwise
190
+ * intentionally left uncategorized.
191
+ * - Unknown tool names are treated as external capabilities and must never
192
+ * silently bypass policy checks.
96
193
  */
97
194
  function getCategoryForTool(toolName, toolArgs = {}) {
98
195
  if (toolName === 'http_request') {
99
196
  const method = (toolArgs.method || 'GET').toUpperCase();
100
197
  return ['GET', 'HEAD', 'OPTIONS'].includes(method) ? null : 'network_write';
101
198
  }
102
- return _toolToCategory[toolName] ?? null;
199
+ if (_toolToCategory[toolName]) {
200
+ return _toolToCategory[toolName];
201
+ }
202
+ if (BUILT_IN_TOOLS.has(toolName) || SAFE_TOOLS.has(toolName)) {
203
+ return null;
204
+ }
205
+ return 'external';
103
206
  }
104
207
 
105
- module.exports = { TOOL_CATEGORIES, SAFE_TOOLS, DEFAULT_POLICY, getCategoryForTool };
208
+ module.exports = {
209
+ TOOL_CATEGORIES,
210
+ SAFE_TOOLS,
211
+ DEFAULT_POLICY,
212
+ BUILT_IN_TOOLS,
213
+ getCategoryForTool,
214
+ };
@@ -63,10 +63,15 @@ function registerToolSecurityHooks(toolPolicyService, approvalGateService) {
63
63
  if (decision === 'approved') return;
64
64
 
65
65
  const isTimeout = decision === 'timeout';
66
+ const isExpired = decision === 'expired';
66
67
  return {
67
68
  block: true,
68
- blocked_by: isTimeout ? 'approval_timeout' : 'user_denied',
69
- reason: isTimeout
69
+ blocked_by: isExpired
70
+ ? 'approval_expired'
71
+ : (isTimeout ? 'approval_timeout' : 'user_denied'),
72
+ reason: isExpired
73
+ ? `Approval for "${toolName}" expired because the server restarted or the run was interrupted. Do not retry unless the user explicitly asks you to try again.`
74
+ : isTimeout
70
75
  ? `Approval for "${toolName}" timed out — the user did not respond within 30 seconds. ` +
71
76
  `Do not retry unless the user explicitly asks you to try again.`
72
77
  : `The user denied the use of "${toolName}". Do not retry this tool call in this run.`,
@@ -21,6 +21,8 @@ function serializeInstalledSkill(skill) {
21
21
  autoCreated: skill.metadata?.auto_created === true,
22
22
  filePath: skill.filePath,
23
23
  storeId: skill.metadata?.store_id || '',
24
+ readOnly: skill.readOnly === true,
25
+ ownerType: skill.ownerType || 'global',
24
26
  };
25
27
  }
26
28
 
@@ -294,6 +294,61 @@ class WorkspaceManager {
294
294
  }
295
295
  }
296
296
 
297
+ replaceFileRange(userId, options = {}) {
298
+ let filePath;
299
+ try {
300
+ filePath = this.resolvePath(userId, options.path || '', 'path');
301
+ if (!fs.existsSync(filePath)) {
302
+ return { success: false, error: `File not found: ${filePath}`, path: filePath };
303
+ }
304
+
305
+ const start = Number(options.start_line ?? options.startLine);
306
+ const end = Number(options.end_line ?? options.endLine ?? start);
307
+ if (!Number.isInteger(start) || start < 1) {
308
+ return { success: false, error: 'start_line must be a positive integer', path: filePath };
309
+ }
310
+ if (!Number.isInteger(end) || end < 1) {
311
+ return { success: false, error: 'end_line must be a positive integer', path: filePath };
312
+ }
313
+ if (start > end) {
314
+ return { success: false, error: 'start_line must be less than or equal to end_line', path: filePath };
315
+ }
316
+
317
+ const original = fs.readFileSync(filePath, 'utf8').replace(/\r\n/g, '\n');
318
+ const hadFinalNewline = original.endsWith('\n');
319
+ const lines = original.split('\n');
320
+ if (hadFinalNewline) lines.pop();
321
+ const totalLines = Math.max(lines.length, 1);
322
+ if (start > totalLines || end > totalLines) {
323
+ return {
324
+ success: false,
325
+ error: `line range ${start}-${end} is outside file with ${totalLines} lines`,
326
+ path: filePath,
327
+ totalLines,
328
+ };
329
+ }
330
+
331
+ const replacementText = String(options.content ?? '').replace(/\r\n/g, '\n');
332
+ const replacementLines = replacementText === ''
333
+ ? []
334
+ : replacementText.replace(/\n$/, '').split('\n');
335
+ lines.splice(start - 1, end - start + 1, ...replacementLines);
336
+ const next = `${lines.join('\n')}${hadFinalNewline ? '\n' : ''}`;
337
+ fs.writeFileSync(filePath, next, 'utf8');
338
+ return {
339
+ success: true,
340
+ path: filePath,
341
+ startLine: start,
342
+ endLine: end,
343
+ replacedLines: end - start + 1,
344
+ insertedLines: replacementLines.length,
345
+ totalLines: lines.length,
346
+ };
347
+ } catch (err) {
348
+ return { success: false, path: filePath || null, error: err.message };
349
+ }
350
+ }
351
+
297
352
  listDirectory(userId, options = {}) {
298
353
  const dirPath = this.resolvePath(userId, options.path || '.', 'path');
299
354
  const depthValue = options.depth != null ? Number(options.depth) : (options.recursive ? 3 : 1);
@@ -414,4 +469,5 @@ class WorkspaceManager {
414
469
 
415
470
  module.exports = {
416
471
  WorkspaceManager,
472
+ sanitizeWorkspaceKey,
417
473
  };