neoagent 3.0.0 → 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.
@@ -309,6 +309,34 @@ class MemoryManager {
309
309
  return resolveAgentId(userId, options?.agentId || options?.agent_id || null);
310
310
  }
311
311
 
312
+ _findExactMemory(userId, agentId, memoryHash, scope) {
313
+ return db.prepare(
314
+ `SELECT id FROM memories
315
+ WHERE user_id = ? AND agent_id = ? AND archived = 0
316
+ AND memory_hash = ?
317
+ AND COALESCE(scope_type, 'agent') = ?
318
+ AND COALESCE(scope_id, '') = COALESCE(?, '')
319
+ LIMIT 1`
320
+ ).get(userId, agentId, memoryHash, scope.scopeType, scope.scopeId);
321
+ }
322
+
323
+ _reinforceExactMemory(memoryId, importance) {
324
+ db.prepare(
325
+ `UPDATE memories
326
+ SET importance = MAX(importance, ?),
327
+ confidence = MIN(1, confidence + 0.03),
328
+ updated_at = datetime('now')
329
+ WHERE id = ?`
330
+ ).run(importance, memoryId);
331
+ db.prepare(
332
+ `UPDATE memory_facts
333
+ SET confidence = MIN(1, confidence + 0.03),
334
+ updated_at = datetime('now')
335
+ WHERE memory_id = ? AND status = 'active'`
336
+ ).run(memoryId);
337
+ return memoryId;
338
+ }
339
+
312
340
  _backfillMemoryIntelligence(limit = 1000) {
313
341
  try {
314
342
  const rows = db.prepare(
@@ -1602,29 +1630,9 @@ class MemoryManager {
1602
1630
  );
1603
1631
  const embedding = embeddingResult?.vector || null;
1604
1632
 
1605
- const exact = db.prepare(
1606
- `SELECT id FROM memories
1607
- WHERE user_id = ? AND agent_id = ? AND archived = 0
1608
- AND memory_hash = ?
1609
- AND COALESCE(scope_type, 'agent') = ?
1610
- AND COALESCE(scope_id, '') = COALESCE(?, '')
1611
- LIMIT 1`
1612
- ).get(userId, agentId, memoryHash, scope.scopeType, scope.scopeId);
1633
+ const exact = this._findExactMemory(userId, agentId, memoryHash, scope);
1613
1634
  if (exact?.id) {
1614
- db.prepare(
1615
- `UPDATE memories
1616
- SET importance = MAX(importance, ?),
1617
- confidence = MIN(1, confidence + 0.03),
1618
- updated_at = datetime('now')
1619
- WHERE id = ?`
1620
- ).run(importance, exact.id);
1621
- db.prepare(
1622
- `UPDATE memory_facts
1623
- SET confidence = MIN(1, confidence + 0.03),
1624
- updated_at = datetime('now')
1625
- WHERE memory_id = ? AND status = 'active'`
1626
- ).run(exact.id);
1627
- return exact.id;
1635
+ return this._reinforceExactMemory(exact.id, importance);
1628
1636
  }
1629
1637
 
1630
1638
  const duplicateCandidateIds = embedding
@@ -1717,8 +1725,8 @@ class MemoryManager {
1717
1725
 
1718
1726
  // Save new
1719
1727
  const id = uuidv4();
1720
- db.prepare(
1721
- `INSERT INTO memories (
1728
+ const insertResult = db.prepare(
1729
+ `INSERT OR IGNORE INTO memories (
1722
1730
  id, user_id, agent_id, category, scope_type, scope_id, source_type, source_id, source_label,
1723
1731
  stale_after_days, metadata_json, content, summary, importance, confidence, memory_hash, embedding,
1724
1732
  embedding_provider, embedding_model, embedding_dimensions, embedded_at
@@ -1747,6 +1755,13 @@ class MemoryManager {
1747
1755
  embeddingResult?.dimensions || null,
1748
1756
  embedding ? new Date().toISOString() : null,
1749
1757
  );
1758
+ if (insertResult.changes === 0) {
1759
+ const existingExact = this._findExactMemory(userId, agentId, memoryHash, scope);
1760
+ if (existingExact?.id) {
1761
+ return this._reinforceExactMemory(existingExact.id, importance);
1762
+ }
1763
+ throw new Error('Memory insert was ignored but no matching memory row was found');
1764
+ }
1750
1765
 
1751
1766
  this._upsertMemoryIntelligence(userId, agentId, id, {
1752
1767
  content,
@@ -161,9 +161,13 @@ class RuntimeManager {
161
161
  }
162
162
 
163
163
  async shutdown() {
164
- await Promise.allSettled([
164
+ const tasks = [
165
165
  this.browserBackend?.shutdown?.(),
166
- ]);
166
+ ];
167
+ if (typeof this.shellWorkerPool?.shutdown === 'function') {
168
+ tasks.push(Promise.resolve().then(() => this.shellWorkerPool.shutdown()));
169
+ }
170
+ await Promise.allSettled(tasks);
167
171
  }
168
172
  }
169
173
 
@@ -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